From d3a5726858cebc84e3556e926a9ea6237fe21b7c Mon Sep 17 00:00:00 2001 From: Ross and Zober Date: Sun, 5 Apr 2026 10:26:00 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Claude=20Code=20channel=20support?= =?UTF-8?q?=20=E2=80=94=20forward=20inbound=20WhatsApp=20messages=20as=20c?= =?UTF-8?q?hannel=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the MCP channel contract so incoming WhatsApp messages are pushed into the Claude Code session automatically via notifications/claude/channel. Changes: - Declare claude/channel experimental capability in initialize response - Add server instructions telling Claude how to handle inbound messages - Spawn a writer thread that owns stdout for immediate async writes - Spawn SSE forwarder thread that connects to /api/events on the daemon, parses inbound events, and pushes notifications/claude/channel with chat_jid, sender, message_id, timestamp, is_from_me, chat_type as meta - Auto-reconnects with exponential backoff on daemon restart README: add Claude Code ~/.claude.json config snippet and systemd user service setup for persistent daemon auto-start on boot. --- Cargo.lock | 10 +++ README.md | 63 ++++++++++++++++- src/mcp.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25423b7..d2b0c82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2271,6 +2271,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "aes", "aes-gcm", @@ -2312,6 +2313,7 @@ dependencies = [ [[package]] name = "wacore-appstate" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "anyhow", "bytemuck", @@ -2332,6 +2334,7 @@ dependencies = [ [[package]] name = "wacore-binary" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "flate2", "phf", @@ -2343,6 +2346,7 @@ dependencies = [ [[package]] name = "wacore-derive" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "proc-macro2", "quote", @@ -2352,6 +2356,7 @@ dependencies = [ [[package]] name = "wacore-libsignal" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "aes", "aes-gcm", @@ -2383,6 +2388,7 @@ dependencies = [ [[package]] name = "wacore-noise" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "aes-gcm", "anyhow", @@ -2401,6 +2407,7 @@ dependencies = [ [[package]] name = "waproto" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "prost", "prost-build", @@ -2522,6 +2529,7 @@ dependencies = [ [[package]] name = "whatsapp-rust" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "anyhow", "async-channel", @@ -2553,6 +2561,7 @@ dependencies = [ [[package]] name = "whatsapp-rust-tokio-transport" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "anyhow", "async-channel", @@ -2572,6 +2581,7 @@ dependencies = [ [[package]] name = "whatsapp-rust-ureq-http-client" version = "0.5.0" +source = "git+https://github.com/199-biotechnologies/whatsapp-rust?rev=9fb13a7bb1f91c3e32eccc517db2566068f15f33#9fb13a7bb1f91c3e32eccc517db2566068f15f33" dependencies = [ "anyhow", "async-trait", diff --git a/README.md b/README.md index c2de1d0..2548125 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,68 @@ Async sends return `{ok, job_id}`. Add `?sync=true` to wait for the WhatsApp mes Run `whatsrust mcp` to start a Model Context Protocol server with 30 tools over JSON-RPC stdio. Connect it to Claude Code, Cursor, or any MCP-compatible AI agent and your agent can send messages, manage groups, post stories, and handle chat operations through WhatsApp. -Works out of the box with Claude Code -- just add it to your MCP config and your AI agent gets full WhatsApp access. +### Claude Code setup + +Build the release binary, then add whatsrust to `~/.claude.json`: + +```bash +cargo build --release +``` + +```json +{ + "mcpServers": { + "whatsrust": { + "command": "/path/to/whatsrust/target/release/whatsrust", + "args": ["mcp"], + "cwd": "/path/to/whatsrust", + "env": { + "WHATSRUST_DB": "/path/to/whatsrust/whatsapp.db" + } + } + } +} +``` + +The MCP server proxies to the running daemon over HTTP — the daemon must be running for MCP tools to work. + +### Run the daemon persistently (Linux) + +Use a systemd user service so the daemon starts automatically on login and restarts on crash: + +```bash +mkdir -p ~/.config/systemd/user +cat > ~/.config/systemd/user/whatsrust.service << 'EOF' +[Unit] +Description=WhatsRust WhatsApp daemon +After=network.target + +[Service] +ExecStart=/path/to/whatsrust/target/release/whatsrust +WorkingDirectory=/path/to/whatsrust +Restart=on-failure +RestartSec=5 +StandardOutput=append:/path/to/whatsrust/daemon.log +StandardError=append:/path/to/whatsrust/daemon.log + +[Install] +WantedBy=default.target +EOF + +systemctl --user daemon-reload +systemctl --user enable --now whatsrust + +# Enable auto-start at boot (without requiring login) +loginctl enable-linger $USER +``` + +Useful service commands: + +```bash +systemctl --user status whatsrust # check status +systemctl --user restart whatsrust # restart +journalctl --user -u whatsrust -f # live logs +``` --- diff --git a/src/mcp.rs b/src/mcp.rs index 4d37611..0d65bc3 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -3,8 +3,14 @@ //! Proxies tool calls to the running whatsrust daemon via its HTTP API. //! Start with `whatsrust mcp` — typically invoked by Claude Code or other //! MCP-compatible AI tool harnesses. +//! +//! ## Channel support +//! When the daemon's SSE stream (`GET /api/events`) emits an `inbound` event, +//! this server pushes a `notifications/claude/channel` notification to Claude Code +//! so incoming WhatsApp messages arrive in the session automatically. use std::io::{BufRead, Write}; +use std::sync::mpsc; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -12,11 +18,26 @@ use serde_json::{json, Value}; /// Run the MCP server on stdin/stdout. Blocks until EOF. pub fn run_mcp_server(port: u16) { let stdin = std::io::stdin(); - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - // Send server info on startup (initialize handshake) - // MCP clients send initialize first, but we also need to be ready to handle it. + // All stdout writes go through this channel so both the stdin handler and + // the SSE forwarder can write without needing stdout to be Send. + let (write_tx, write_rx) = mpsc::channel::(); + + // Writer thread: owns stdout and flushes every line immediately. + { + let write_tx_sse = write_tx.clone(); + // SSE forwarder — sends notifications directly to the writer. + std::thread::spawn(move || { + sse_channel_forwarder(port, write_tx_sse); + }); + } + std::thread::spawn(move || { + let mut stdout = std::io::stdout(); + for line in write_rx { + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); + } + }); for line in stdin.lock().lines() { let line = match line { @@ -30,7 +51,7 @@ pub fn run_mcp_server(port: u16) { Ok(r) => r, Err(e) => { let err = json_rpc_error(Value::Null, -32700, &format!("parse error: {e}")); - let _ = writeln!(stdout, "{}", serde_json::to_string(&err).unwrap()); + let _ = write_tx.send(serde_json::to_string(&err).unwrap()); continue; } }; @@ -39,12 +60,159 @@ pub fn run_mcp_server(port: u16) { let is_notification = req.id.is_null() || req.method.starts_with("notifications/"); let response = handle_rpc(&req, port); if !is_notification { - let _ = writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()); - let _ = stdout.flush(); + let _ = write_tx.send(serde_json::to_string(&response).unwrap()); + } + } +} + +/// Connects to the daemon's SSE stream and forwards every `inbound` event to +/// Claude Code as a `notifications/claude/channel` notification. +/// Reconnects with backoff on disconnect (daemon restart, network hiccup, etc.). +fn sse_channel_forwarder(port: u16, tx: mpsc::Sender) { + use std::io::BufRead; + use std::net::TcpStream; + use std::time::Duration; + + let mut backoff = Duration::from_secs(1); + + loop { + if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)) { + // Send HTTP GET /api/events request + { + use std::io::Write as _; + let mut s = stream.try_clone().unwrap(); + let req = format!( + "GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAccept: text/event-stream\r\nConnection: keep-alive\r\n\r\n" + ); + if s.write_all(req.as_bytes()).is_err() { + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + } + + backoff = Duration::from_secs(1); // reset on successful connect + let reader = std::io::BufReader::new(stream); + let mut past_headers = false; + let mut event_type = String::new(); + let mut data_buf = String::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + + // Skip HTTP response headers + if !past_headers { + if line.is_empty() { past_headers = true; } + continue; + } + + // SSE chunked transfer may prefix data length lines — skip hex lines + if line.chars().all(|c| c.is_ascii_hexdigit()) && !line.is_empty() { + continue; + } + + if line.starts_with("event:") { + event_type = line[6..].trim().to_string(); + } else if line.starts_with("data:") { + data_buf = line[5..].trim().to_string(); + } else if line.is_empty() { + if event_type == "inbound" && !data_buf.is_empty() { + if let Ok(msg) = serde_json::from_str::(&data_buf) { + let notification = build_channel_notification(&msg); + if let Ok(serialized) = serde_json::to_string(¬ification) { + if tx.send(serialized).is_err() { + return; // main thread gone, exit + } + } + } + } + event_type.clear(); + data_buf.clear(); + } + } } + + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_secs(30)); } } +/// Build the `notifications/claude/channel` JSON-RPC notification for an inbound message. +fn build_channel_notification(msg: &Value) -> Value { + let chat_jid = msg.get("jid").and_then(|v| v.as_str()).unwrap_or(""); + let sender = msg.get("sender").and_then(|v| v.as_str()).unwrap_or(""); + let message_id = msg.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let timestamp = msg.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + let is_from_me = msg.get("is_from_me").and_then(|v| v.as_bool()).unwrap_or(false); + let is_group = msg.get("is_group").and_then(|v| v.as_bool()).unwrap_or(false); + let chat_type = if is_group { "group" } else { "individual" }; + + // Extract display text from content + let content = extract_display_text(msg); + + json!({ + "jsonrpc": "2.0", + "method": "notifications/claude/channel", + "params": { + "content": content, + "meta": { + "chat_jid": chat_jid, + "sender": sender, + "message_id": message_id, + "timestamp": timestamp.to_string(), + "is_from_me": is_from_me.to_string(), + "chat_type": chat_type + } + } + }) +} + +/// Extract a human-readable string from the inbound message's content field. +fn extract_display_text(msg: &Value) -> String { + if let Some(content) = msg.get("content") { + // Text message + if let Some(text) = content.get("Text").and_then(|v| v.as_str()) { + return text.to_string(); + } + // Image/video/audio with caption + for kind in &["Image", "Video", "Audio", "Document", "Sticker"] { + if let Some(media) = content.get(kind) { + let caption = media.get("caption").and_then(|v| v.as_str()).unwrap_or(""); + return if caption.is_empty() { + format!("[{}]", kind.to_lowercase()) + } else { + format!("[{}] {}", kind.to_lowercase(), caption) + }; + } + } + // Reaction + if let Some(reaction) = content.get("Reaction") { + let emoji = reaction.get("emoji").and_then(|v| v.as_str()).unwrap_or("?"); + return format!("[reaction: {}]", emoji); + } + // Poll + if let Some(poll) = content.get("PollCreated") { + let question = poll.get("question").and_then(|v| v.as_str()).unwrap_or("poll"); + return format!("[poll: {}]", question); + } + // Location + if content.get("Location").is_some() { + return "[location]".to_string(); + } + // Contact + if let Some(contact) = content.get("Contact") { + let name = contact.get("display_name").and_then(|v| v.as_str()).unwrap_or("contact"); + return format!("[contact: {}]", name); + } + // Fallback: serialize the whole content + return serde_json::to_string(content).unwrap_or_default(); + } + String::new() +} + #[derive(Deserialize)] struct JsonRpcRequest { #[allow(dead_code)] @@ -83,11 +251,26 @@ fn handle_rpc(req: &JsonRpcRequest, port: u16) -> JsonRpcResponse { match req.method.as_str() { "initialize" => json_rpc_ok(req.id.clone(), json!({ "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, + "capabilities": { + "tools": {}, + "experimental": { "claude/channel": {} } + }, "serverInfo": { "name": "whatsrust", "version": env!("CARGO_PKG_VERSION") - } + }, + "instructions": "You are a WhatsApp bridge assistant.\n\ + \n\ + ## Incoming WhatsApp messages\n\ + Messages arrive as .\n\ + - If is_from_me=\"true\" — these are messages the owner sent themselves; ignore unless they are commands.\n\ + - Otherwise: a new WhatsApp message has arrived. Read the chat_jid, sender, and content.\n\ + \n\ + ## Sending messages\n\ + Use whatsrust_send to send a message. Always confirm with the owner before sending unless explicitly told to act autonomously.\n\ + \n\ + ## Getting context\n\ + Call whatsrust_history with the chat_jid to read recent conversation before replying." })), "notifications/initialized" => json_rpc_ok(req.id.clone(), json!({})), "tools/list" => json_rpc_ok(req.id.clone(), json!({ "tools": tool_definitions() })), From 87b05ba16430f285f5bc1931667c7088eafcc73c Mon Sep 17 00:00:00 2001 From: Ross and Zober Date: Sun, 5 Apr 2026 11:23:11 +0700 Subject: [PATCH 2/3] feat: image tool accepts local path and https:// URL - mcp.rs: whatsrust_image now accepts 'path' (file or URL) in addition to inline base64 'data'; resolves to base64 before forwarding to API - api.rs: /api/image (and all media endpoints) handle https:// paths by fetching the URL via ureq; local-path mode unchanged (loopback-only) - Cargo.toml: add ureq = '3' as direct dependency --- Cargo.lock | 2 ++ Cargo.toml | 1 + src/api.rs | 63 ++++++++++++++++++++++++++++++++++++++++-------------- src/mcp.rs | 53 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d2b0c82..291db74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,7 @@ checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64", "cookie_store", + "flate2", "log", "percent-encoding", "rustls", @@ -2616,6 +2617,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "ureq", "wacore", "wacore-binary", "waproto", diff --git a/Cargo.toml b/Cargo.toml index be3aa50..80ab78e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Our own rusqlite backend (src/storage.rs) replaces it. # For local dev, .cargo/config.toml patches these to ../whatsapp-rust path deps. whatsapp-rust = { git = "https://github.com/199-biotechnologies/whatsapp-rust", rev = "9fb13a7bb1f91c3e32eccc517db2566068f15f33", default-features = false, features = ["tokio-runtime", "tokio-transport", "ureq-client", "moka-cache", "simd", "signal", "tokio-native"] } +ureq = "3" wacore = { git = "https://github.com/199-biotechnologies/whatsapp-rust", rev = "9fb13a7bb1f91c3e32eccc517db2566068f15f33" } wacore-binary = { git = "https://github.com/199-biotechnologies/whatsapp-rust", rev = "9fb13a7bb1f91c3e32eccc517db2566068f15f33" } waproto = { git = "https://github.com/199-biotechnologies/whatsapp-rust", rev = "9fb13a7bb1f91c3e32eccc517db2566068f15f33" } diff --git a/src/api.rs b/src/api.rs index 8758611..ba509d5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -940,23 +940,54 @@ async fn handle_media(bridge: &WhatsAppBridge, body: &[u8], is_loopback: bool, k let fname = req.filename.clone().unwrap_or_else(|| "file".to_string()); (bytes, mime, fname) } else if let Some(ref path_str) = req.path { - // Path mode — loopback-only - if !is_loopback { - return json_err(403, "local-path media uploads are disabled for non-loopback API binds"); + if path_str.starts_with("http://") || path_str.starts_with("https://") { + // URL mode — fetch from network, allowed from any bind + let url = path_str.clone(); + let result = tokio::task::spawn_blocking(move || { + let resp = ureq::get(&url) + .call() + .map_err(|e| format!("failed to fetch '{url}': {e}"))?; + let ct = resp.headers().get("content-type") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(str::trim) + .unwrap_or("application/octet-stream") + .to_owned(); + let bytes = resp.into_body().read_to_vec() + .map_err(|e| format!("failed to read response body: {e}"))?; + Ok::<(Vec, String), String>((bytes, ct)) + }).await; + let (bytes, ct) = match result { + Ok(Ok(v)) => v, + Ok(Err(e)) => return json_err(400, &e), + Err(e) => return json_err(500, &format!("spawn_blocking failed: {e}")), + }; + if bytes.is_empty() { return json_err(400, "fetched URL returned empty body"); } + if bytes.len() as u64 > MAX_MEDIA_READ_BYTES { + return json_err(400, &format!("URL content exceeds size limit ({} bytes)", bytes.len())); + } + let mime = req.mime.clone().unwrap_or(ct); + let fname = path_str.split('/').last().and_then(|s| s.split('?').next()).unwrap_or("file").to_string(); + (bytes, mime, fname) + } else { + // Local path mode — loopback-only + if !is_loopback { + return json_err(403, "local-path media uploads are disabled for non-loopback API binds"); + } + let bytes = match read_file_for_media(path_str).await { Ok(d) => d, Err(e) => return e }; + let path = std::path::Path::new(path_str); + let mime = req.mime.clone().unwrap_or_else(|| match kind { + MediaKind::Image => mime_for_image(path).to_string(), + MediaKind::Video => mime_for_video(path).to_string(), + MediaKind::Audio => mime_for_audio(path).to_string(), + MediaKind::Doc => mime_for_doc(path).to_string(), + MediaKind::Sticker => "image/webp".to_string(), + MediaKind::ViewOnceImage => mime_for_image(path).to_string(), + MediaKind::ViewOnceVideo => mime_for_video(path).to_string(), + }); + let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string(); + (bytes, mime, fname) } - let bytes = match read_file_for_media(path_str).await { Ok(d) => d, Err(e) => return e }; - let path = std::path::Path::new(path_str); - let mime = req.mime.clone().unwrap_or_else(|| match kind { - MediaKind::Image => mime_for_image(path).to_string(), - MediaKind::Video => mime_for_video(path).to_string(), - MediaKind::Audio => mime_for_audio(path).to_string(), - MediaKind::Doc => mime_for_doc(path).to_string(), - MediaKind::Sticker => "image/webp".to_string(), - MediaKind::ViewOnceImage => mime_for_image(path).to_string(), - MediaKind::ViewOnceVideo => mime_for_video(path).to_string(), - }); - let fname = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string(); - (bytes, mime, fname) } else { return json_err(400, "either 'path' (loopback) or 'data' (base64) is required"); }; diff --git a/src/mcp.rs b/src/mcp.rs index 0d65bc3..7285993 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -325,12 +325,14 @@ fn tool_definitions() -> Vec { json!({"type":"object","properties":{ "jid":{"type":"string"},"id":{"type":"string"} },"required":["jid","id"]})), - tool_def("whatsrust_image", "Send an image (base64)", + tool_def("whatsrust_image", "Send an image (base64 or file path)", json!({"type":"object","properties":{ - "jid":{"type":"string"},"data":{"type":"string","description":"Base64-encoded image"}, + "jid":{"type":"string"}, + "data":{"type":"string","description":"Base64-encoded image (mutually exclusive with path)"}, + "path":{"type":"string","description":"Absolute local file path or https:// URL (mutually exclusive with data)"}, "mime":{"type":"string","description":"MIME type (default image/jpeg)"}, "caption":{"type":"string"} - },"required":["jid","data"]})), + },"required":["jid"]})), tool_def("whatsrust_groups", "List all joined groups", json!({"type":"object","properties":{}})), tool_def("whatsrust_group_info", "Get group details and members", json!({"type":"object","properties":{"jid":{"type":"string"}},"required":["jid"]})), @@ -464,7 +466,50 @@ fn call_tool(name: &str, args: &Value, port: u16) -> Value { "whatsrust_react" => http_post(port, "/api/react", args), "whatsrust_edit" => http_post(port, "/api/edit", args), "whatsrust_revoke" => http_post(port, "/api/revoke", args), - "whatsrust_image" => http_post(port, "/api/image", args), + "whatsrust_image" => { + // If data (base64) is already provided, forward as-is. + // If path is provided (local file or https:// URL), resolve it to base64 here + // and send only {jid, data, mime, caption} — never forward `path` to the API. + if args.get("data").and_then(|v| v.as_str()).is_some() { + http_post(port, "/api/image", args) + } else if let Some(path) = args.get("path").and_then(|v| v.as_str()).map(str::to_owned) { + let (bytes, detected_mime) = if path.starts_with("http://") || path.starts_with("https://") { + let resp = match ureq::get(&path).call() { + Ok(r) => r, + Err(e) => return json!({ "content": [{ "type": "text", "text": format!("failed to fetch '{path}': {e}") }], "isError": true }), + }; + let ct = resp.headers().get("content-type") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(';').next()) + .map(str::trim) + .unwrap_or("image/jpeg") + .to_owned(); + let b = match resp.into_body().read_to_vec() { + Ok(b) => b, + Err(e) => return json!({ "content": [{ "type": "text", "text": format!("failed to read response from '{path}': {e}") }], "isError": true }), + }; + (b, ct) + } else { + let b = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => return json!({ "content": [{ "type": "text", "text": format!("cannot read image file '{path}': {e}") }], "isError": true }), + }; + let mime = if path.ends_with(".png") { "image/png" } + else if path.ends_with(".gif") { "image/gif" } + else if path.ends_with(".webp") { "image/webp" } + else { "image/jpeg" }; + (b, mime.to_owned()) + }; + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mime = args.get("mime").and_then(|v| v.as_str()).unwrap_or(&detected_mime).to_owned(); + let mut payload = json!({"jid": args["jid"], "data": b64, "mime": mime}); + if let Some(cap) = args.get("caption") { payload["caption"] = cap.clone(); } + http_post(port, "/api/image", &payload) + } else { + return json!({ "content": [{ "type": "text", "text": "whatsrust_image: provide either 'data' (base64) or 'path' (file path or URL)" }], "isError": true }); + } + } "whatsrust_typing" => http_post(port, "/api/typing", args), "whatsrust_location" => http_post(port, "/api/location", args), "whatsrust_contact" => http_post(port, "/api/contact", args), From cdad3976a3e418425836fcefd19f8a276d80f45f Mon Sep 17 00:00:00 2001 From: Ross and Zober Date: Sun, 5 Apr 2026 11:39:00 +0700 Subject: [PATCH 3/3] fix: resolve all clippy warnings and add tests for new mcp features - clamp() instead of .max().min() chains (api.rs) - next_back() for DoubleEndedIterator::last (api.rs) - Default impl for BridgeMetrics (bridge.rs) - Entry API instead of contains_key + insert (bridge.rs) - #[allow] for borrowed_box on BoundedMsgCache::get (bridge.rs) - #[allow(clippy::too_many_arguments)] on send_message_with_preview and handle_outbound (bridge.rs) - Remove needless borrows on get_client_handle calls (bridge.rs) - Remove needless borrow on serde_json::to_value(status) (bridge.rs) - is_empty() companion for AtomicDedupCache::len (dedup.rs) - strip_prefix() instead of manual index slicing (mcp.rs) - #[allow(dead_code)] on extract_image_dimensions (media_utils.rs) - #[allow(clippy::should_implement_trait)] on from_str (outbound.rs) - Collapsible str::replace with char array (outbound.rs) - div_ceil() instead of manual ceil formula (qr.rs) - is_some_and() instead of map_or(false, ...) (storage.rs) - .copied() instead of .map(|s| *s) (main.rs) New tests (mcp.rs): 15 unit tests covering extract_display_text for all content kinds, build_channel_notification metadata and chat_type, and mcp_connect_host wildcard/specific address handling. --- daemon.log | 197 +++++++++++++++++++++++++++++++++++++++++++++ src/api.rs | 6 +- src/bridge.rs | 22 +++-- src/dedup.rs | 6 ++ src/main.rs | 4 +- src/mcp.rs | 161 +++++++++++++++++++++++++++++++++++- src/media_utils.rs | 1 + src/outbound.rs | 4 +- src/qr.rs | 2 +- src/storage.rs | 2 +- 10 files changed, 385 insertions(+), 20 deletions(-) create mode 100644 daemon.log diff --git a/daemon.log b/daemon.log new file mode 100644 index 0000000..12b7ae8 --- /dev/null +++ b/daemon.log @@ -0,0 +1,197 @@ +2026-04-05T04:02:17.093225Z  INFO whatsrust: whatsrust v0.3.0 +2026-04-05T04:02:17.093370Z  INFO whatsrust: sender allowlist active allowed=["84949764200", "85295155050", "85254284261", "14159000264", "120363406674111320", "120363401793588075", "120363422422191807", "120363425637859297", "120363422485699740", "120363400316788731", "120363108273613941", "120363407178910362", "120363405318981374"] +2026-04-05T04:02:17.094597Z  INFO whatsrust: session lock acquired lock=whatsapp.db.instance.lock +2026-04-05T04:02:17.097145Z  INFO whatsrust: bridge started, state: Disconnected +2026-04-05T04:02:17.097165Z  INFO whatsrust::bridge: WhatsApp bridge starting +2026-04-05T04:02:17.097216Z  INFO whatsrust: waiting for WhatsApp connection (scan QR code or enter pair code)... +Commands: + send — send text (prints msg ID) + reply — reply quoting a message +2026-04-05T04:02:17.097393Z  INFO whatsrust::api: API server listening bind=127.0.0.1 port=7270 + edit — edit a sent message + react [from_me] [sender_jid] — react to a message + unreact [from_me] [sender_jid] — remove a reaction + revoke — delete a message for everyone + image — send an image file + audio — send audio as voice note + video — send a video file + doc — send a document/file + sticker — send a WebP sticker + location — send a location pin + contact — send a contact card + forward — forward a cached message (fwd) + vo-image [cap] — send view-once image + vo-video [cap] — send view-once video + poll | opt | ... — create a poll (N = selectable count) + subscribe — subscribe to contact presence + typing — show typing indicator + stop-typing — cancel typing indicator + recording — show recording indicator + stop-recording — cancel recording indicator + status — show bridge state + quit — shut down + +2026-04-05T04:02:17.115762Z  INFO whatsrust::bridge: startup backup complete path=whatsapp.db.backups/whatsapp_backup_1775361737.db +2026-04-05T04:02:17.117420Z  INFO whatsapp_rust::bot: Applying device props override: os=Some("Habb"), version=None, platform_type=None +2026-04-05T04:02:17.117467Z  INFO whatsapp_rust::bot: Creating client... +2026-04-05T04:02:17.845016Z  INFO whatsapp_rust::handshake: Handshake complete, switching to encrypted communication +2026-04-05T04:02:18.150233Z  INFO whatsapp_rust::client: Successfully authenticated with WhatsApp servers! (gen=1) +2026-04-05T04:02:19.289229Z  INFO whatsrust::bridge: offline sync completed — real-time messages active count=0 +2026-04-05T04:02:19.289387Z  INFO whatsrust::bridge: WhatsApp connected +2026-04-05T04:02:58.085258Z  INFO whatsrust::bridge: delivery receipt receipt_type=Delivered status=Delivered message_count=1 chat=85254284261@s.whatsapp.net sender=85254284261 + +<< [85254284261@s.whatsapp.net] 85254284261 (delivery-receipt): [receipt: delivered for 1 message(s)] +> 2026-04-05T04:02:59.212883Z  INFO whatsrust::bridge: delivery receipt receipt_type=Sender status=Sent message_count=1 chat=84949764200@s.whatsapp.net sender=84949764200 + +<< [84949764200@s.whatsapp.net] 84949764200 (delivery-receipt): [receipt: sent for 1 message(s)] +> 2026-04-05T04:03:00.133456Z  INFO whatsrust::bridge: delivery receipt receipt_type=Read status=Read message_count=1 chat=85254284261@s.whatsapp.net sender=85254284261 + +<< [85254284261@s.whatsapp.net] 85254284261 (delivery-receipt): [receipt: read for 1 message(s)] +> 2026-04-05T04:07:23.293974Z  INFO whatsrust: SIGTERM received +2026-04-05T04:07:23.294023Z  INFO whatsrust: shutting down... +2026-04-05T04:07:23.294109Z  INFO whatsrust::bridge: bridge shutdown requested — draining in-flight operations +2026-04-05T04:07:23.294297Z  INFO whatsapp_rust::client: Disconnecting client intentionally. +2026-04-05T04:07:23.294401Z  INFO whatsrust::bridge: graceful shutdown complete +2026-04-05T04:07:23.308665Z  INFO whatsrust::bridge: shutdown backup complete path=whatsapp.db.backups/whatsapp_backup_1775362043.db +2026-04-05T04:07:23.308699Z  INFO whatsrust::bridge: WhatsApp bridge stopped +2026-04-05T04:07:23.318894Z  INFO whatsrust: whatsrust v0.3.0 +2026-04-05T04:07:23.318941Z  INFO whatsrust: sender allowlist active allowed=["84949764200", "85295155050", "85254284261", "14159000264", "120363406674111320", "120363401793588075", "120363422422191807", "120363425637859297", "120363422485699740", "120363400316788731", "120363108273613941", "120363407178910362", "120363405318981374"] +2026-04-05T04:07:23.319963Z  INFO whatsrust: session lock acquired lock=whatsapp.db.instance.lock +2026-04-05T04:07:23.322877Z  INFO whatsrust: bridge started, state: Disconnected +2026-04-05T04:07:23.322907Z  INFO whatsrust: waiting for WhatsApp connection (scan QR code or enter pair code)... +2026-04-05T04:07:23.322906Z  INFO whatsrust::bridge: WhatsApp bridge starting +Commands: + send — send text (prints msg ID) + reply — reply quoting a message + edit — edit a sent message + react [from_me] [sender_jid] — react to a message + unreact [from_me] [sender_jid] — remove a reaction + revoke — delete a message for everyone + image — send an image file +2026-04-05T04:07:23.322969Z  INFO whatsrust::api: API server listening bind=127.0.0.1 port=7270 + audio — send audio as voice note + video — send a video file + doc — send a document/file + sticker — send a WebP sticker + location — send a location pin + contact — send a contact card + forward — forward a cached message (fwd) + vo-image [cap] — send view-once image + vo-video [cap] — send view-once video + poll | opt | ... — create a poll (N = selectable count) + subscribe — subscribe to contact presence + typing — show typing indicator + stop-typing — cancel typing indicator + recording — show recording indicator + stop-recording — cancel recording indicator + status — show bridge state + quit — shut down + +2026-04-05T04:07:23.339054Z  INFO whatsrust::bridge: startup backup complete path=whatsapp.db.backups/whatsapp_backup_1775362043.db +2026-04-05T04:07:23.339649Z  INFO whatsapp_rust::bot: Applying device props override: os=Some("Habb"), version=None, platform_type=None +2026-04-05T04:07:23.339684Z  INFO whatsapp_rust::bot: Creating client... +2026-04-05T04:07:24.267182Z  INFO whatsapp_rust::handshake: Handshake complete, switching to encrypted communication +2026-04-05T04:07:24.523985Z  INFO whatsapp_rust::client: Successfully authenticated with WhatsApp servers! (gen=1) +2026-04-05T04:07:25.139515Z  INFO whatsrust::bridge: offline sync completed — real-time messages active count=0 +2026-04-05T04:07:25.139562Z  INFO whatsrust::bridge: WhatsApp connected +2026-04-05T04:12:50.071371Z  INFO whatsrust: SIGTERM received +2026-04-05T04:12:50.073581Z  INFO whatsrust::bridge: bridge shutdown requested — draining in-flight operations +2026-04-05T04:12:50.073739Z  INFO whatsrust: shutting down... +2026-04-05T04:12:50.073963Z  INFO whatsapp_rust::client: Disconnecting client intentionally. +2026-04-05T04:12:50.074055Z  INFO whatsrust::bridge: graceful shutdown complete +2026-04-05T04:12:50.088320Z  INFO whatsrust::bridge: shutdown backup complete path=whatsapp.db.backups/whatsapp_backup_1775362370.db +2026-04-05T04:12:50.088376Z  INFO whatsrust::bridge: WhatsApp bridge stopped +2026-04-05T04:12:50.103043Z  INFO whatsrust: whatsrust v0.3.0 +2026-04-05T04:12:50.103089Z  INFO whatsrust: sender allowlist active allowed=["84949764200", "85295155050", "85254284261", "14159000264", "120363406674111320", "120363401793588075", "120363422422191807", "120363425637859297", "120363422485699740", "120363400316788731", "120363108273613941", "120363407178910362", "120363405318981374"] +2026-04-05T04:12:50.104139Z  INFO whatsrust: session lock acquired lock=whatsapp.db.instance.lock +2026-04-05T04:12:50.106600Z  INFO whatsrust: bridge started, state: Disconnected +2026-04-05T04:12:50.106794Z  INFO whatsrust: waiting for WhatsApp connection (scan QR code or enter pair code)... +2026-04-05T04:12:50.106700Z  INFO whatsrust::bridge: WhatsApp bridge starting +2026-04-05T04:12:50.107148Z  INFO whatsrust::api: API server listening bind=127.0.0.1 port=7270 +Commands: + send — send text (prints msg ID) + reply — reply quoting a message + edit — edit a sent message + react [from_me] [sender_jid] — react to a message + unreact [from_me] [sender_jid] — remove a reaction + revoke — delete a message for everyone + image — send an image file + audio — send audio as voice note + video — send a video file + doc — send a document/file + sticker — send a WebP sticker + location — send a location pin + contact — send a contact card + forward — forward a cached message (fwd) + vo-image [cap] — send view-once image + vo-video [cap] — send view-once video + poll | opt | ... — create a poll (N = selectable count) + subscribe — subscribe to contact presence + typing — show typing indicator + stop-typing — cancel typing indicator + recording — show recording indicator + stop-recording — cancel recording indicator + status — show bridge state + quit — shut down + +2026-04-05T04:12:50.124671Z  INFO whatsrust::bridge: startup backup complete path=whatsapp.db.backups/whatsapp_backup_1775362370.db +2026-04-05T04:12:50.125548Z  INFO whatsapp_rust::bot: Applying device props override: os=Some("Habb"), version=None, platform_type=None +2026-04-05T04:12:50.125593Z  INFO whatsapp_rust::bot: Creating client... +2026-04-05T04:12:50.764828Z  INFO whatsapp_rust::handshake: Handshake complete, switching to encrypted communication +2026-04-05T04:12:51.070630Z  INFO whatsapp_rust::client: Successfully authenticated with WhatsApp servers! (gen=1) +2026-04-05T04:12:51.892516Z  INFO whatsrust::bridge: offline sync completed — real-time messages active count=0 +2026-04-05T04:12:51.892644Z  INFO whatsrust::bridge: WhatsApp connected +2026-04-05T04:16:56.228316Z  INFO whatsrust: SIGTERM received +2026-04-05T04:16:56.228374Z  INFO whatsrust: shutting down... +2026-04-05T04:16:56.242655Z  INFO whatsrust::bridge: shutdown backup complete path=whatsapp.db.backups/whatsapp_backup_1775362616.db +2026-04-05T04:16:56.242777Z  INFO whatsrust::bridge: WhatsApp bridge stopped +2026-04-05T04:16:56.257313Z  INFO whatsrust: whatsrust v0.3.0 +2026-04-05T04:16:56.257355Z  INFO whatsrust: sender allowlist active allowed=["84949764200", "85295155050", "85254284261", "14159000264", "120363406674111320", "120363401793588075", "120363422422191807", "120363425637859297", "120363422485699740", "120363400316788731", "120363108273613941", "120363407178910362", "120363405318981374"] +2026-04-05T04:16:56.258436Z  INFO whatsrust: session lock acquired lock=whatsapp.db.instance.lock +2026-04-05T04:16:56.261460Z  INFO whatsrust: bridge started, state: Disconnected +2026-04-05T04:16:56.261481Z  INFO whatsrust: waiting for WhatsApp connection (scan QR code or enter pair code)... +2026-04-05T04:16:56.261478Z  INFO whatsrust::bridge: WhatsApp bridge starting +2026-04-05T04:16:56.261537Z  INFO whatsrust::api: API server listening bind=127.0.0.1 port=7270 +Commands: + send — send text (prints msg ID) + reply — reply quoting a message + edit — edit a sent message + react [from_me] [sender_jid] — react to a message + unreact [from_me] [sender_jid] — remove a reaction + revoke — delete a message for everyone + image — send an image file + audio — send audio as voice note + video — send a video file + doc — send a document/file + sticker — send a WebP sticker + location — send a location pin + contact — send a contact card + forward — forward a cached message (fwd) + vo-image [cap] — send view-once image + vo-video [cap] — send view-once video + poll | opt | ... — create a poll (N = selectable count) + subscribe — subscribe to contact presence + typing — show typing indicator + stop-typing — cancel typing indicator + recording — show recording indicator + stop-recording — cancel recording indicator + status — show bridge state + quit — shut down + +2026-04-05T04:16:56.277697Z  INFO whatsrust::bridge: startup backup complete path=whatsapp.db.backups/whatsapp_backup_1775362616.db +2026-04-05T04:16:56.278289Z  INFO whatsapp_rust::bot: Applying device props override: os=Some("Habb"), version=None, platform_type=None +2026-04-05T04:16:56.278315Z  INFO whatsapp_rust::bot: Creating client... +2026-04-05T04:16:57.030650Z  INFO whatsapp_rust::handshake: Handshake complete, switching to encrypted communication +2026-04-05T04:16:57.337459Z  INFO whatsapp_rust::client: Successfully authenticated with WhatsApp servers! (gen=1) +2026-04-05T04:16:58.430929Z  INFO whatsrust::bridge: offline sync completed — real-time messages active count=0 +2026-04-05T04:16:58.431058Z  INFO whatsrust::bridge: WhatsApp connected +2026-04-05T04:17:10.239536Z  INFO whatsrust::bridge: delivery receipt receipt_type=Delivered status=Delivered message_count=1 chat=85254284261@s.whatsapp.net sender=85254284261 + +<< [85254284261@s.whatsapp.net] 85254284261 (delivery-receipt): [receipt: delivered for 1 message(s)] +> 2026-04-05T04:17:10.548799Z  INFO whatsrust::bridge: delivery receipt receipt_type=Sender status=Sent message_count=1 chat=84949764200@s.whatsapp.net sender=84949764200 + +<< [84949764200@s.whatsapp.net] 84949764200 (delivery-receipt): [receipt: sent for 1 message(s)] +> 2026-04-05T04:17:12.702311Z  INFO whatsrust::bridge: delivery receipt receipt_type=Read status=Read message_count=1 chat=85254284261@s.whatsapp.net sender=85254284261 + +<< [85254284261@s.whatsapp.net] 85254284261 (delivery-receipt): [receipt: read for 1 message(s)] +> 2026-04-05T04:27:13.983382Z  INFO whatsapp_rust::client: Received ping, sending pong. diff --git a/src/api.rs b/src/api.rs index ba509d5..e281b58 100644 --- a/src/api.rs +++ b/src/api.rs @@ -885,7 +885,7 @@ fn mime_for_doc(path: &std::path::Path) -> &'static str { async fn handle_history(bridge: &WhatsAppBridge, req: &HttpRequest) -> Vec { let jid = req.query_get("jid"); - let limit: i64 = req.query_get("limit").and_then(|v| v.parse().ok()).unwrap_or(50).max(1).min(200); + let limit: i64 = req.query_get("limit").and_then(|v| v.parse().ok()).unwrap_or(50).clamp(1, 200); let before: Option = req.query_get("before").and_then(|v| v.parse().ok()); match bridge.store().search_inbound(jid, None, limit, before).await { Ok(rows) => { @@ -902,7 +902,7 @@ async fn handle_search(bridge: &WhatsAppBridge, req: &HttpRequest) -> Vec { _ => return json_err(400, "query parameter 'q' is required"), }; let jid = req.query_get("jid"); - let limit: i64 = req.query_get("limit").and_then(|v| v.parse().ok()).unwrap_or(20).max(1).min(200); + let limit: i64 = req.query_get("limit").and_then(|v| v.parse().ok()).unwrap_or(20).clamp(1, 200); match bridge.store().search_inbound(jid, Some(q), limit, None).await { Ok(rows) => { let count = rows.len(); @@ -967,7 +967,7 @@ async fn handle_media(bridge: &WhatsAppBridge, body: &[u8], is_loopback: bool, k return json_err(400, &format!("URL content exceeds size limit ({} bytes)", bytes.len())); } let mime = req.mime.clone().unwrap_or(ct); - let fname = path_str.split('/').last().and_then(|s| s.split('?').next()).unwrap_or("file").to_string(); + let fname = path_str.split('/').next_back().and_then(|s| s.split('?').next()).unwrap_or("file").to_string(); (bytes, mime, fname) } else { // Local path mode — loopback-only diff --git a/src/bridge.rs b/src/bridge.rs index afac30c..5ca85c4 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -142,6 +142,12 @@ pub struct BridgeMetrics { pub last_outbound_epoch: AtomicU64, } +impl Default for BridgeMetrics { + fn default() -> Self { + Self::new() + } +} + impl BridgeMetrics { pub fn new() -> Self { Self { @@ -760,9 +766,9 @@ impl BoundedMsgCache { } fn insert(&mut self, id: String, msg: Box) { - if self.map.contains_key(&id) { + if let std::collections::hash_map::Entry::Occupied(mut e) = self.map.entry(id.clone()) { // Already cached — update in place, don't grow order - self.map.insert(id, msg); + e.insert(msg); return; } while self.map.len() >= self.capacity { @@ -776,6 +782,7 @@ impl BoundedMsgCache { self.map.insert(id, msg); } + #[allow(clippy::borrowed_box)] fn get(&self, id: &str) -> Option<&Box> { self.map.get(id) } @@ -887,7 +894,7 @@ impl WhatsAppBridge { } /// Send a text message with a link preview card. - #[allow(dead_code)] // Public API for library consumers + #[allow(dead_code, clippy::too_many_arguments)] // Public API for library consumers pub async fn send_message_with_preview( &self, jid: &str, @@ -2236,7 +2243,7 @@ async fn run_bot_session( } } } - _ = handle_outbound(&client_handle, cancel, store, config.max_outbound_retries, metrics, send_pacer, outbound_notify, event_tx) => { + _ = handle_outbound(client_handle, cancel, store, config.max_outbound_retries, metrics, send_pacer, outbound_notify, event_tx) => { if cancel.is_cancelled() || stop_reconnect.load(Ordering::Relaxed) { Ok(SessionAction::Stop) } else { @@ -2249,7 +2256,7 @@ async fn run_bot_session( // Drain: attempt to send queued jobs if we still have a connection let drain_result = tokio::time::timeout(drain_timeout, async { - if let Some(client) = get_client_handle(&client_handle) { + if let Some(client) = get_client_handle(client_handle) { let mut sent = 0u32; while let Ok(Some(row)) = store.claim_next_job().await { let jid = match parse_jid(&row.jid) { @@ -2288,7 +2295,7 @@ async fn run_bot_session( if drain_result.is_err() { warn!(timeout_secs = config.drain_timeout_secs, "shutdown drain timed out"); - if let Some(client) = get_client_handle(&client_handle) { + if let Some(client) = get_client_handle(client_handle) { client.disconnect().await; } } @@ -2673,7 +2680,7 @@ async fn handle_event( ); // Update delivery status for queued jobs by WA message ID - let status_str = serde_json::to_value(&status) + let status_str = serde_json::to_value(status) .ok() .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_else(|| format!("{status:?}").to_lowercase()); @@ -3530,6 +3537,7 @@ async fn resolve_sender(sender_raw: &str, sender_alt: Option<&wacore_binary::jid // Outbound message handling // --------------------------------------------------------------------------- +#[allow(clippy::too_many_arguments)] async fn handle_outbound( client_handle: &Arc>>>, cancel: &CancellationToken, diff --git a/src/dedup.rs b/src/dedup.rs index e3e8064..d5f58a3 100644 --- a/src/dedup.rs +++ b/src/dedup.rs @@ -93,6 +93,12 @@ impl AtomicDedupCache { pub fn len(&self) -> usize { self.map.len() } + + /// Returns true if the cache contains no entries. + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 1b09524..1e715d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -880,7 +880,7 @@ async fn main() -> Result<()> { if parts.len() < 3 { eprintln!("usage: delete-for-me [sender] [from_me]"); } else { - let sender = parts.get(3).map(|s| *s); + let sender = parts.get(3).copied(); let from_me = parts.get(4).map(|v| *v != "false").unwrap_or(true); match bridge_for_repl.delete_message_for_me(parts[1], parts[2], sender, from_me).await { Ok(()) => println!("ok"), @@ -892,7 +892,7 @@ async fn main() -> Result<()> { if parts.len() < 3 { eprintln!("usage: {} [sender] [from_me]", parts[0]); } else { - let sender = parts.get(3).map(|s| *s); + let sender = parts.get(3).copied(); let from_me = parts.get(4).map(|v| *v != "false").unwrap_or(true); let result = if parts[0] == "star" { bridge_for_repl.star_message(parts[1], parts[2], sender, from_me).await diff --git a/src/mcp.rs b/src/mcp.rs index 7285993..f626d88 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -114,10 +114,10 @@ fn sse_channel_forwarder(port: u16, tx: mpsc::Sender) { continue; } - if line.starts_with("event:") { - event_type = line[6..].trim().to_string(); - } else if line.starts_with("data:") { - data_buf = line[5..].trim().to_string(); + if let Some(rest) = line.strip_prefix("event:") { + event_type = rest.trim().to_string(); + } else if let Some(rest) = line.strip_prefix("data:") { + data_buf = rest.trim().to_string(); } else if line.is_empty() { if event_type == "inbound" && !data_buf.is_empty() { if let Ok(msg) = serde_json::from_str::(&data_buf) { @@ -618,3 +618,156 @@ fn mcp_connect_host() -> String { bind } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // ----------------------------------------------------------------------- + // extract_display_text + // ----------------------------------------------------------------------- + + #[test] + fn extract_display_text_returns_text_for_text_message() { + let msg = json!({ "content": { "Text": "hello world" } }); + assert_eq!(extract_display_text(&msg), "hello world"); + } + + #[test] + fn extract_display_text_returns_bracketed_kind_for_media_without_caption() { + for kind in &["Image", "Video", "Audio", "Document", "Sticker"] { + let mut content = serde_json::Map::new(); + content.insert(kind.to_string(), json!({})); + let msg = json!({ "content": content }); + let expected = format!("[{}]", kind.to_lowercase()); + assert_eq!(extract_display_text(&msg), expected, "failed for kind {kind}"); + } + } + + #[test] + fn extract_display_text_includes_caption_when_present() { + let msg = json!({ "content": { "Image": { "caption": "a sunset" } } }); + assert_eq!(extract_display_text(&msg), "[image] a sunset"); + } + + #[test] + fn extract_display_text_returns_reaction_with_emoji() { + let msg = json!({ "content": { "Reaction": { "emoji": "👍" } } }); + assert_eq!(extract_display_text(&msg), "[reaction: 👍]"); + } + + #[test] + fn extract_display_text_returns_poll_with_question() { + let msg = json!({ "content": { "PollCreated": { "question": "Best language?" } } }); + assert_eq!(extract_display_text(&msg), "[poll: Best language?]"); + } + + #[test] + fn extract_display_text_returns_location_placeholder() { + let msg = json!({ "content": { "Location": {} } }); + assert_eq!(extract_display_text(&msg), "[location]"); + } + + #[test] + fn extract_display_text_returns_contact_with_name() { + let msg = json!({ "content": { "Contact": { "display_name": "Alice" } } }); + assert_eq!(extract_display_text(&msg), "[contact: Alice]"); + } + + #[test] + fn extract_display_text_returns_empty_when_no_content() { + let msg = json!({ "jid": "123@s.whatsapp.net" }); + assert_eq!(extract_display_text(&msg), ""); + } + + // ----------------------------------------------------------------------- + // build_channel_notification + // ----------------------------------------------------------------------- + + #[test] + fn build_channel_notification_sets_jsonrpc_method() { + let msg = json!({ + "jid": "1234@s.whatsapp.net", + "sender": "1234@s.whatsapp.net", + "id": "MSGID", + "timestamp": 1700000000i64, + "is_from_me": false, + "is_group": false, + "content": { "Text": "hi" } + }); + let notif = build_channel_notification(&msg); + assert_eq!(notif["jsonrpc"], "2.0"); + assert_eq!(notif["method"], "notifications/claude/channel"); + } + + #[test] + fn build_channel_notification_sets_individual_chat_type_for_dm() { + let msg = json!({ + "jid": "1234@s.whatsapp.net", + "sender": "1234@s.whatsapp.net", + "id": "X", + "timestamp": 0i64, + "is_from_me": false, + "is_group": false, + "content": { "Text": "dm" } + }); + let notif = build_channel_notification(&msg); + assert_eq!(notif["params"]["meta"]["chat_type"], "individual"); + } + + #[test] + fn build_channel_notification_sets_group_chat_type_for_group() { + let msg = json!({ + "jid": "group@g.us", + "sender": "1234@s.whatsapp.net", + "id": "X", + "timestamp": 0i64, + "is_from_me": false, + "is_group": true, + "content": { "Text": "group msg" } + }); + let notif = build_channel_notification(&msg); + assert_eq!(notif["params"]["meta"]["chat_type"], "group"); + } + + #[test] + fn build_channel_notification_content_matches_display_text() { + let msg = json!({ + "jid": "1234@s.whatsapp.net", + "sender": "1234@s.whatsapp.net", + "id": "X", + "timestamp": 0i64, + "is_from_me": false, + "is_group": false, + "content": { "Text": "hello" } + }); + let notif = build_channel_notification(&msg); + assert_eq!(notif["params"]["content"], "hello"); + } + + // ----------------------------------------------------------------------- + // mcp_connect_host + // ----------------------------------------------------------------------- + + #[test] + fn mcp_connect_host_maps_wildcard_to_loopback() { + std::env::set_var("WHATSRUST_BIND", "0.0.0.0"); + assert_eq!(mcp_connect_host(), "127.0.0.1"); + std::env::remove_var("WHATSRUST_BIND"); + } + + #[test] + fn mcp_connect_host_maps_ipv6_wildcard_to_loopback() { + std::env::set_var("WHATSRUST_BIND", "::"); + assert_eq!(mcp_connect_host(), "127.0.0.1"); + std::env::remove_var("WHATSRUST_BIND"); + } + + #[test] + fn mcp_connect_host_preserves_specific_bind_address() { + std::env::set_var("WHATSRUST_BIND", "192.168.1.10"); + assert_eq!(mcp_connect_host(), "192.168.1.10"); + std::env::remove_var("WHATSRUST_BIND"); + } +} diff --git a/src/media_utils.rs b/src/media_utils.rs index e7e8b9d..b0a96d9 100644 --- a/src/media_utils.rs +++ b/src/media_utils.rs @@ -30,6 +30,7 @@ pub fn extract_image_meta(data: &[u8]) -> Option { } /// Extract just image dimensions from raw bytes (fast path using image crate). +#[allow(dead_code)] pub fn extract_image_dimensions(data: &[u8]) -> Option<(u32, u32)> { // Try header-only decode first for speed #[allow(deprecated)] diff --git a/src/outbound.rs b/src/outbound.rs index 441c818..5861a3b 100644 --- a/src/outbound.rs +++ b/src/outbound.rs @@ -89,7 +89,7 @@ impl OutboundOpKind { /// Deprecated: use `parse_str()` or `.parse::()` instead. #[deprecated(note = "use parse_str() or .parse::() (FromStr) instead")] - #[allow(dead_code)] + #[allow(dead_code, clippy::should_implement_trait)] pub fn from_str(s: &str) -> Option { Self::parse_str(s) } @@ -286,7 +286,7 @@ fn parse_recipient_jids(recipients: &[String]) -> anyhow::Result usize { let quiet = 2; let total = self.size + 2 * quiet; - (total + 1) / 2 // ceil(total / 2) since we pack 2 rows per line + total.div_ceil(2) } /// Auto-refreshing HTML page with the QR code rendered as an inline SVG. diff --git a/src/storage.rs b/src/storage.rs index c5e349d..22ad0c6 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1746,7 +1746,7 @@ impl ProtocolStore for Store { ) .optional() .map_err(db_err)?; - Ok(stored.map_or(false, |s| s == cbk)) + Ok(stored.is_some_and(|s| s == cbk)) }) .await }