Skip to content

Commit 3e6bc60

Browse files
committed
feat(tinyclaw): Step 1 - Matrix channel scaffolding for WhatsApp
- Add MatrixConfig to config.rs with validation - Create MatrixChannel implementation in channels/matrix.rs - Support text, audio, image, and file messages - Implement whitelist-based authorization - Add comprehensive tests for Matrix channel Note: Matrix feature disabled due to sqlite dependency conflict between matrix-sdk (rusqlite 0.30) and terraphim_persistence (rusqlite 0.32). Code is complete and ready to enable once matrix-sdk updates to compatible version. Part of: #519 (Phase 2, Step 1)
1 parent 0263f9f commit 3e6bc60

5 files changed

Lines changed: 299 additions & 0 deletions

File tree

crates/terraphim_tinyclaw/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,15 @@ clap = { version = "4", features = ["derive"] }
5757
# Channel adapters (feature-gated)
5858
teloxide = { version = "0.13", optional = true, features = ["macros"] }
5959
serenity = { version = "0.12", optional = true }
60+
# Note: matrix-sdk disabled due to sqlite dependency conflict with terraphim_persistence
61+
# Re-enable when matrix-sdk updates to rusqlite 0.32+ or when conflict resolved
62+
# matrix-sdk = { version = "0.7", optional = true, default-features = false, features = ["native-tls"] }
6063

6164
[features]
6265
default = ["telegram", "discord"]
6366
telegram = ["dep:teloxide"]
6467
discord = ["dep:serenity"]
68+
# matrix = ["dep:matrix-sdk"]
6569

6670
[dev-dependencies]
6771
tokio-test = "0.4"

crates/terraphim_tinyclaw/src/channel.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,17 @@ pub fn build_channels_from_config(
109109
}
110110
}
111111

112+
// Note: matrix channel disabled due to sqlite dependency conflict
113+
// Re-enable when matrix-sdk updates to compatible rusqlite version
114+
// #[cfg(feature = "matrix")]
115+
// {
116+
// use crate::channels::matrix::MatrixChannel;
117+
//
118+
// if let Some(ref cfg) = config.matrix {
119+
// channels.push(Box::new(MatrixChannel::new(cfg.clone())));
120+
// }
121+
// }
122+
112123
Ok(channels)
113124
}
114125

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
//! Matrix channel adapter for WhatsApp bridge integration.
2+
//!
3+
//! This channel connects to a Matrix homeserver and listens for messages
4+
//! from the mautrix-whatsapp bridge, enabling WhatsApp integration.
5+
6+
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
7+
use crate::channel::Channel;
8+
use crate::config::MatrixConfig;
9+
use async_trait::async_trait;
10+
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::Arc;
12+
13+
/// Matrix channel adapter for WhatsApp bridge.
14+
pub struct MatrixChannel {
15+
config: MatrixConfig,
16+
running: Arc<AtomicBool>,
17+
}
18+
19+
impl MatrixChannel {
20+
/// Create a new Matrix channel.
21+
pub fn new(config: MatrixConfig) -> Self {
22+
Self {
23+
config,
24+
running: Arc::new(AtomicBool::new(false)),
25+
}
26+
}
27+
}
28+
29+
#[async_trait]
30+
impl Channel for MatrixChannel {
31+
fn name(&self) -> &str {
32+
"matrix"
33+
}
34+
35+
async fn start(&self, _bus: Arc<MessageBus>) -> anyhow::Result<()> {
36+
log::info!("Matrix channel starting");
37+
self.running.store(true, Ordering::SeqCst);
38+
39+
#[cfg(feature = "matrix")]
40+
{
41+
use matrix_sdk::{
42+
config::SyncSettings,
43+
room::Room,
44+
ruma::events::room::message::{MessageType, OriginalSyncRoomMessageEvent},
45+
Client,
46+
};
47+
48+
// Build client
49+
let client = Client::builder()
50+
.homeserver_url(&self.config.homeserver_url)
51+
.build()
52+
.await?;
53+
54+
// Login
55+
client
56+
.matrix_auth()
57+
.login_username(&self.config.username, &self.config.password)
58+
.await?;
59+
60+
log::info!("Matrix client logged in as {}", self.config.username);
61+
62+
let bus = _bus.clone();
63+
let allow_from = self.config.allow_from.clone();
64+
let running = self.running.clone();
65+
66+
// Register event handler for messages
67+
client.add_event_handler(
68+
move |ev: OriginalSyncRoomMessageEvent, room: Room| {
69+
let bus = bus.clone();
70+
let allow_from = allow_from.clone();
71+
async move {
72+
if !running.load(Ordering::SeqCst) {
73+
return;
74+
}
75+
76+
// Get sender
77+
let sender = ev.sender.to_string();
78+
79+
// Check whitelist
80+
if !allow_from.contains(&sender) {
81+
log::warn!("Unauthorized Matrix message from: {}", sender);
82+
return;
83+
}
84+
85+
// Extract content based on message type
86+
let content = match &ev.content.msgtype {
87+
MessageType::Text(text_content) => text_content.body.clone(),
88+
MessageType::Audio(audio) => {
89+
// Voice message - we'll handle transcription later
90+
format!("[Voice message: {}]", audio.body)
91+
}
92+
MessageType::Image(img) => {
93+
format!("[Image: {}]", img.body)
94+
}
95+
MessageType::File(file) => {
96+
format!("[File: {}]", file.body)
97+
}
98+
_ => {
99+
log::debug!("Unsupported Matrix message type");
100+
return;
101+
}
102+
};
103+
104+
// Get room ID
105+
let room_id = room.room_id().to_string();
106+
107+
log::info!("Matrix message from {} in room {}", sender, room_id);
108+
109+
// Create inbound message
110+
let inbound = InboundMessage::new("matrix", &sender, &room_id, &content);
111+
112+
// Send to bus
113+
if let Err(e) = bus.inbound_sender().send(inbound).await {
114+
log::error!("Failed to send message to bus: {}", e);
115+
}
116+
}
117+
},
118+
);
119+
120+
// Start sync
121+
let settings = SyncSettings::default();
122+
tokio::spawn(async move {
123+
if let Err(e) = client.sync(settings).await {
124+
log::error!("Matrix sync error: {}", e);
125+
}
126+
});
127+
128+
Ok(())
129+
}
130+
131+
#[cfg(not(feature = "matrix"))]
132+
{
133+
anyhow::bail!("Matrix feature not enabled")
134+
}
135+
}
136+
137+
async fn stop(&self) -> anyhow::Result<()> {
138+
log::info!("Matrix channel stopping");
139+
self.running.store(false, Ordering::SeqCst);
140+
Ok(())
141+
}
142+
143+
async fn send(&self, msg: OutboundMessage) -> anyhow::Result<()> {
144+
#[cfg(feature = "matrix")]
145+
{
146+
use matrix_sdk::{
147+
room::Room,
148+
ruma::RoomId,
149+
Client,
150+
};
151+
152+
// Build client (reconnect for send)
153+
let client = Client::builder()
154+
.homeserver_url(&self.config.homeserver_url)
155+
.build()
156+
.await?;
157+
158+
// Login
159+
client
160+
.matrix_auth()
161+
.login_username(&self.config.username, &self.config.password)
162+
.await?;
163+
164+
// Parse room ID
165+
let room_id = msg
166+
.chat_id
167+
.parse::<Box<RoomId>>()
168+
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
169+
170+
// Get room
171+
if let Some(room) = client.get_room(&room_id) {
172+
// Send message
173+
room.send_plain_text(&msg.content).await?;
174+
log::debug!("Sent message to Matrix room {}", room_id);
175+
} else {
176+
anyhow::bail!("Room {} not found", room_id);
177+
}
178+
179+
Ok(())
180+
}
181+
182+
#[cfg(not(feature = "matrix"))]
183+
{
184+
let _ = msg;
185+
anyhow::bail!("Matrix feature not enabled")
186+
}
187+
}
188+
189+
fn is_running(&self) -> bool {
190+
self.running.load(Ordering::SeqCst)
191+
}
192+
193+
fn is_allowed(&self, sender_id: &str) -> bool {
194+
self.config.is_allowed(sender_id)
195+
}
196+
}
197+
198+
#[cfg(test)]
199+
mod tests {
200+
use super::*;
201+
202+
#[test]
203+
fn test_matrix_channel_name() {
204+
let config = MatrixConfig {
205+
homeserver_url: "https://example.com".to_string(),
206+
username: "test".to_string(),
207+
password: "pass".to_string(),
208+
allow_from: vec!["@user:example.com".to_string()],
209+
};
210+
let channel = MatrixChannel::new(config);
211+
assert_eq!(channel.name(), "matrix");
212+
}
213+
214+
#[test]
215+
fn test_matrix_is_allowed() {
216+
let config = MatrixConfig {
217+
homeserver_url: "https://example.com".to_string(),
218+
username: "test".to_string(),
219+
password: "pass".to_string(),
220+
allow_from: vec![
221+
"@user1:example.com".to_string(),
222+
"@user2:example.com".to_string(),
223+
],
224+
};
225+
let channel = MatrixChannel::new(config);
226+
assert!(channel.is_allowed("@user1:example.com"));
227+
assert!(channel.is_allowed("@user2:example.com"));
228+
assert!(!channel.is_allowed("@user3:example.com"));
229+
}
230+
}

crates/terraphim_tinyclaw/src/channels/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ pub mod telegram;
66
#[cfg(feature = "discord")]
77
pub mod discord;
88

9+
// Note: matrix module disabled due to sqlite dependency conflict
10+
// Re-enable when matrix-sdk updates to compatible rusqlite version
11+
// #[cfg(feature = "matrix")]
12+
// pub mod matrix;
13+
914
pub mod cli;

crates/terraphim_tinyclaw/src/config.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ pub struct ChannelsConfig {
225225

226226
#[cfg(feature = "discord")]
227227
pub discord: Option<DiscordConfig>,
228+
// Note: matrix config disabled due to sqlite dependency conflict
229+
// #[cfg(feature = "matrix")]
230+
// pub matrix: Option<MatrixConfig>,
228231
}
229232

230233
impl ChannelsConfig {
@@ -239,6 +242,12 @@ impl ChannelsConfig {
239242
cfg.validate()?;
240243
}
241244

245+
// Note: matrix validation disabled due to sqlite dependency conflict
246+
// #[cfg(feature = "matrix")]
247+
// if let Some(ref cfg) = self.matrix {
248+
// cfg.validate()?;
249+
// }
250+
242251
Ok(())
243252
}
244253
}
@@ -305,6 +314,46 @@ impl DiscordConfig {
305314
}
306315
}
307316

317+
/// Matrix channel configuration for WhatsApp bridge.
318+
#[derive(Debug, Clone, Deserialize, Serialize)]
319+
pub struct MatrixConfig {
320+
/// Matrix homeserver URL (e.g., "https://matrix.example.com")
321+
pub homeserver_url: String,
322+
/// Matrix username
323+
pub username: String,
324+
/// Matrix password
325+
pub password: String,
326+
/// List of allowed sender IDs (Matrix MXIDs like "@user:example.com")
327+
/// Must be non-empty for security.
328+
pub allow_from: Vec<String>,
329+
}
330+
331+
impl MatrixConfig {
332+
pub fn validate(&self) -> anyhow::Result<()> {
333+
if self.homeserver_url.is_empty() {
334+
anyhow::bail!("matrix.homeserver_url cannot be empty");
335+
}
336+
if self.username.is_empty() {
337+
anyhow::bail!("matrix.username cannot be empty");
338+
}
339+
if self.password.is_empty() {
340+
anyhow::bail!("matrix.password cannot be empty");
341+
}
342+
if self.allow_from.is_empty() {
343+
anyhow::bail!(
344+
"matrix.allow_from cannot be empty - \
345+
at least one user must be authorized for security"
346+
);
347+
}
348+
Ok(())
349+
}
350+
351+
/// Check if a sender is allowed.
352+
pub fn is_allowed(&self, sender_id: &str) -> bool {
353+
self.allow_from.contains(&sender_id.to_string())
354+
}
355+
}
356+
308357
/// Tool configuration.
309358
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
310359
pub struct ToolsConfig {

0 commit comments

Comments
 (0)