diff --git a/CLAUDE.md b/CLAUDE.md index 5d2069c..8c1139e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ All four must pass before any commit. - **Config structs are dumb data.** No methods on config types beyond `Deserialize`. Logic lives in the components that consume them. - **Workspace reference must stay behind `Arc`.** Never hold a raw `WorkspaceHandle` in a listener. This is critical for V2 `/use` compatibility. - **Factory lookup, not per-message allocation.** `backend::build(name)` is the factory. `startup::build_workspaces()` builds one `Arc` per distinct backend name, stored in a `HashMap`. The router looks up by name. -- **MessageContext stamping at ingestion.** Channel providers stamp every `InboundMessage` with workspace `Arc`, provider `Arc`, and effective output config. The router reads routing info directly from the message — no lookup tables. +- **MessageContext stamping at ingestion.** Channel providers use `InboundMessage::new(chat_id, user_id, text, &workspace, &provider, &output_config)` which clones the `Arc`s internally. The router reads routing info directly from the message — no lookup tables. - **`self_arc` on `start()`.** `ChannelProvider::start()` takes a separate `self_arc: Arc` because polling closures need owned captures. A `&self` borrow doesn't live long enough. ### Error Handling @@ -143,15 +143,14 @@ All four must pass before any commit. 1. Create `src/channel/.rs` with a struct that implements `ChannelProvider` and `ChannelProviderFactory`. 2. Add a module-level `resolve_users(users: &[AllowedUser]) -> Result>` function (or `async fn` if network I/O is needed) — convert `AllowedUser` entries to platform-native ID strings for `SecurityGate`. See `docs/architecture.md` for examples. 3. Implement `ChannelProviderFactory::create(ch_config, workspace, global_output)` — validate provider-specific config fields, call `resolve_users(&ch_config.allowed_users)`, build `SecurityGate::new(resolved)`, compute effective output config, then construct the provider once. -4. Implement `start(&self, tx, self_arc, shutdown)` — run the polling or webhook loop, check `SecurityGate`, stamp each message with `MessageContext`, send on `tx`. Exit when `shutdown.cancelled()` resolves. Use `self_arc` (not `self`) inside any closures that need to reference the provider. +4. Implement `start(&self, tx, self_arc, shutdown)` — run the polling or webhook loop, check `SecurityGate`, build messages via `InboundMessage::new(chat_id, user_id, text, &workspace, &self_arc, &output_config)`, send on `tx`. Exit when `shutdown.cancelled()` resolves. Use `self_arc` (not `self`) inside any closures that need to reference the provider. 5. Implement `send_response(&self, chat_id, response)` — deliver each `ResponseChunk` to the platform. -6. Stamp `MessageContext` at ingestion: `workspace: Arc`, `provider: self_arc.clone()`, `output_config: Arc::new(effective_output_config(global, channel_cfg))`. -7. Add `pub mod ;` in `src/channel/mod.rs`. -8. Add the kind string to `KNOWN_CHANNELS` in `src/config.rs`. -9. Add a match arm for the new kind in `build()` in `src/channel/mod.rs`. -10. Add `warn_misplaced_fields()` entries in `src/config.rs` for any platform-specific config fields. -11. Add tests in `src/tests/channel/_test.rs` and wire with `#[path = ...]` in the source file. -12. Update the channels table in `README.md` and add a field reference section in `docs/configuration.md`. +6. Add `pub mod ;` in `src/channel/mod.rs`. +7. Add the kind string to `KNOWN_CHANNELS` in `src/config.rs`. +8. Add a match arm for the new kind in `build()` in `src/channel/mod.rs`. +9. Add `warn_misplaced_fields()` entries in `src/config.rs` for any platform-specific config fields. +10. Add tests in `src/tests/channel/_test.rs` and wire with `#[path = ...]` in the source file. +11. Update the channels table in `README.md` and add a field reference section in `docs/configuration.md`. ## Do Not diff --git a/README.md b/README.md index 4df600f..b846955 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,12 @@ RustifyMyClaw runs locally. Messages in -> directly to your Agent, responses out ## Features -- Code-block-aware output chunking — fenced blocks never split mid-block, UTF-8 safe -- Auto file upload when responses exceed a configurable threshold -- Per-user rate limiting with config hot-reload (no restart needed) -- Graceful shutdown with 30s in-flight message drain -- Per-workspace process timeout to prevent runaway sessions -- Env var interpolation for all secrets — zero hardcoded tokens -- 140+ tests, zero clippy warnings, trait-based extensibility -- Single binary, cross-platform (Linux, macOS, Windows) +- Single binary, no dependencies. +- Env var interpolation for all secrets, zero hardcoded tokens. +- Per-workspace process timeout to prevent runaway sessions. +- 140+ tests, zero clippy warnings, trait-based extensibility. +- Block-aware output chunking/formatting - natural chat feeling. +- Graceful shutdown with 30s in-flight message drain. ## Quickstart diff --git a/docs/architecture.md b/docs/architecture.md index 3b99795..3029668 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ A message from Telegram to a response back: 1. `TelegramProvider` receives an update via teloxide long-polling. 2. `SecurityGate` checks `user_id` against the channel's `allowed_users` set. If not allowed, drop silently. -3. Provider stamps an `InboundMessage` with `MessageContext` (workspace `Arc`, provider `Arc`, effective output config) and sends it on the `mpsc` channel. +3. Provider creates an `InboundMessage` via `InboundMessage::new(chat_id, user_id, text, &workspace, &provider, &output_config)` and sends it on the `mpsc` channel. 4. `Router` receives the message and calls `BridgeCommand::parse(&msg.text)`. 5. If it's `/new`, `/status`, `/help`, or `/use` — handle directly and respond via `msg.context.provider.send_response()`. 6. If it's a `Prompt`, check rate limit. If limited, send a "try again in N seconds" reply. @@ -164,7 +164,7 @@ Each provider module defines a `resolve_users` function (free function, not a tr `ChannelProviderFactory::create()` calls the module's `resolve_users` function, builds `SecurityGate::new(resolved)`, computes the effective output config, then constructs the provider once. No temporary instances, no dummy gates. -The `self_arc` parameter on `start()` exists because polling closures need owned captures of the provider. Pass it through to any closure that stamps `MessageContext`. +The `self_arc` parameter on `start()` exists because polling closures need owned captures of the provider. Pass it to `InboundMessage::new()` as the provider reference. `channel::build()` dispatches by kind string and is the single entry point called from `startup::build_workspaces()`. diff --git a/src/channel/mod.rs b/src/channel/mod.rs index 465b2b5..9430d64 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -43,7 +43,7 @@ pub trait ChannelProvider: Send + Sync { /// Factory trait for constructing a [`ChannelProvider`] from configuration. /// /// Each provider implements this to encapsulate its own config-field validation, -/// user resolution, and [`SecurityGate`] construction. The companion [`build`] +/// user resolution, and [`SecurityGate`](crate::security::SecurityGate) construction. The companion [`build`] /// function dispatches to the correct implementation by channel kind. #[async_trait] pub trait ChannelProviderFactory: ChannelProvider + Sized { diff --git a/src/channel/slack.rs b/src/channel/slack.rs index fb139af..427bd87 100644 --- a/src/channel/slack.rs +++ b/src/channel/slack.rs @@ -14,8 +14,7 @@ use crate::channel::{ChannelProvider, ChannelProviderFactory}; use crate::config::{self, ChannelConfig, OutputConfig}; use crate::security::SecurityGate; use crate::types::{ - AllowedUser, ChannelKind, ChatId, FormattedResponse, InboundMessage, MessageContext, - ResponseChunk, WorkspaceHandle, + AllowedUser, ChatId, FormattedResponse, InboundMessage, ResponseChunk, WorkspaceHandle, }; const SLACK_API_BASE: &str = "https://slack.com/api"; @@ -315,20 +314,15 @@ impl ChannelProvider for SlackProvider { .insert(channel_id.clone(), msg_ts); } - let chat_id = ChatId { - channel: ChannelKind::Slack, - platform_id: channel_id, - }; - let inbound = InboundMessage { + let chat_id = ChatId::slack(&channel_id); + let inbound = InboundMessage::new( chat_id, user_id, text, - context: MessageContext { - workspace: Arc::clone(&self.workspace), - provider: Arc::clone(&self_arc), - output_config: Arc::clone(&self.output_config), - }, - }; + &self.workspace, + &self_arc, + &self.output_config, + ); if tx.send(inbound).await.is_err() { tracing::error!("router channel closed — cannot forward slack message"); } diff --git a/src/channel/telegram.rs b/src/channel/telegram.rs index bed6dad..70c5649 100644 --- a/src/channel/telegram.rs +++ b/src/channel/telegram.rs @@ -14,8 +14,7 @@ use crate::channel::{ChannelProvider, ChannelProviderFactory}; use crate::config::{self, ChannelConfig, OutputConfig}; use crate::security::SecurityGate; use crate::types::{ - AllowedUser, ChannelKind, ChatId, FormattedResponse, InboundMessage, MessageContext, - ResponseChunk, WorkspaceHandle, + AllowedUser, ChatId, FormattedResponse, InboundMessage, ResponseChunk, WorkspaceHandle, }; const TELEGRAM_MAX_CHARS: usize = 4096; @@ -108,21 +107,16 @@ impl ChannelProvider for TelegramProvider { return Ok(()); } - let chat_id = ChatId { - channel: ChannelKind::Telegram, - platform_id: msg.chat.id.0.to_string(), - }; + let chat_id = ChatId::telegram(&msg.chat.id.0.to_string()); - let inbound = InboundMessage { + let inbound = InboundMessage::new( chat_id, user_id, - text: text.to_string(), - context: MessageContext { - workspace: Arc::clone(&workspace), - provider, - output_config: Arc::clone(&output_config), - }, - }; + text.to_string(), + &workspace, + &provider, + &output_config, + ); if tx.send(inbound).await.is_err() { tracing::error!("router channel closed — cannot forward telegram message"); diff --git a/src/channel/whatsapp.rs b/src/channel/whatsapp.rs index fc43316..70486d9 100644 --- a/src/channel/whatsapp.rs +++ b/src/channel/whatsapp.rs @@ -18,8 +18,7 @@ use crate::channel::{ChannelProvider, ChannelProviderFactory}; use crate::config::{self, ChannelConfig, OutputConfig}; use crate::security::SecurityGate; use crate::types::{ - AllowedUser, ChannelKind, ChatId, FormattedResponse, InboundMessage, MessageContext, - ResponseChunk, WorkspaceHandle, + AllowedUser, ChatId, FormattedResponse, InboundMessage, ResponseChunk, WorkspaceHandle, }; const WHATSAPP_MAX_CHARS: usize = 4096; @@ -167,6 +166,17 @@ struct OutboundMessage { text: OutboundText, } +impl OutboundMessage { + fn text(to: &str, body: String) -> Self { + Self { + messaging_product: "whatsapp", + to: to.to_string(), + kind: "text", + text: OutboundText { body }, + } + } +} + #[derive(Debug, Serialize)] struct OutboundText { body: String, @@ -206,20 +216,15 @@ async fn handle_inbound( tracing::trace!(user_id, "unauthorized whatsapp message — dropped"); continue; } - let chat_id = ChatId { - channel: ChannelKind::WhatsApp, - platform_id: user_id.clone(), - }; - let inbound = InboundMessage { + let chat_id = ChatId::whatsapp(&user_id); + let inbound = InboundMessage::new( chat_id, user_id, - text: text_obj.body, - context: MessageContext { - workspace: Arc::clone(&state.workspace), - provider: Arc::clone(&state.provider), - output_config: Arc::clone(&state.output_config), - }, - }; + text_obj.body, + &state.workspace, + &state.provider, + &state.output_config, + ); if state.tx.send(inbound).await.is_err() { tracing::error!("router channel closed — cannot forward whatsapp message"); } @@ -273,12 +278,7 @@ impl ChannelProvider for WhatsAppProvider { match chunk { ResponseChunk::Text(text) => { let safe = enforce_whatsapp_limit(&text); - let body = OutboundMessage { - messaging_product: "whatsapp", - to: chat_id.platform_id.clone(), - kind: "text", - text: OutboundText { body: safe }, - }; + let body = OutboundMessage::text(&chat_id.platform_id, safe); self.http_client .post(&url) .bearer_auth(&self.api_token) @@ -300,12 +300,7 @@ impl ChannelProvider for WhatsAppProvider { let notice = format!("[File `{name}` ({} bytes) — see CLI output]", content.len()); let safe = enforce_whatsapp_limit(¬ice); - let body = OutboundMessage { - messaging_product: "whatsapp", - to: chat_id.platform_id.clone(), - kind: "text", - text: OutboundText { body: safe }, - }; + let body = OutboundMessage::text(&chat_id.platform_id, safe); self.http_client .post(&url) .bearer_auth(&self.api_token) diff --git a/src/cli/config_cmd/mod.rs b/src/cli/config_cmd/mod.rs index ecfc215..2d90b64 100644 --- a/src/cli/config_cmd/mod.rs +++ b/src/cli/config_cmd/mod.rs @@ -30,7 +30,7 @@ pub enum ConfigAction { }, /// Read a single config value by dotted path (e.g. output.max_message_chars). Get { - /// Dotted path to the value (e.g. workspaces[0].backend). + /// Dotted path to the value (e.g. `workspaces[0].backend`). key: String, }, /// Write a single config value by dotted path. diff --git a/src/tests/session_test.rs b/src/tests/session_test.rs index 2e59911..40f3713 100644 --- a/src/tests/session_test.rs +++ b/src/tests/session_test.rs @@ -1,30 +1,15 @@ use super::*; -use crate::types::ChannelKind; - -fn tg_chat(id: &str) -> ChatId { - ChatId { - channel: ChannelKind::Telegram, - platform_id: id.to_string(), - } -} - -fn wa_chat(id: &str) -> ChatId { - ChatId { - channel: ChannelKind::WhatsApp, - platform_id: id.to_string(), - } -} #[test] fn fresh_chat_is_not_active() { let store = SessionStore::new(); - assert!(!store.get(&tg_chat("42")).is_active); + assert!(!store.get(&ChatId::telegram("42")).is_active); } #[test] fn after_mark_active_should_continue() { let mut store = SessionStore::new(); - let id = tg_chat("42"); + let id = ChatId::telegram("42"); store.mark_active(&id); assert!(store.get(&id).is_active); } @@ -32,7 +17,7 @@ fn after_mark_active_should_continue() { #[test] fn after_reset_is_not_active() { let mut store = SessionStore::new(); - let id = tg_chat("42"); + let id = ChatId::telegram("42"); store.mark_active(&id); store.reset(&id); assert!(!store.get(&id).is_active); @@ -41,8 +26,8 @@ fn after_reset_is_not_active() { #[test] fn different_platforms_same_id_are_independent() { let mut store = SessionStore::new(); - let tg = tg_chat("12345"); - let wa = wa_chat("12345"); + let tg = ChatId::telegram("12345"); + let wa = ChatId::whatsapp("12345"); store.mark_active(&tg); assert!(store.get(&tg).is_active); assert!(!store.get(&wa).is_active); @@ -51,5 +36,5 @@ fn different_platforms_same_id_are_independent() { #[test] fn reset_nonexistent_is_noop() { let mut store = SessionStore::new(); - store.reset(&tg_chat("nonexistent")); // should not panic + store.reset(&ChatId::telegram("nonexistent")); // should not panic } diff --git a/src/types.rs b/src/types.rs index 84a441f..76bd4a0 100644 --- a/src/types.rs +++ b/src/types.rs @@ -28,6 +28,32 @@ pub struct ChatId { pub platform_id: String, } +impl ChatId { + /// Create a Telegram chat ID from a stringified `i64` chat ID. + pub fn telegram(platform_id: &str) -> Self { + Self { + channel: ChannelKind::Telegram, + platform_id: platform_id.to_string(), + } + } + + /// Create a WhatsApp chat ID from a phone number (e.g. `+5511999999999`). + pub fn whatsapp(platform_id: &str) -> Self { + Self { + channel: ChannelKind::WhatsApp, + platform_id: platform_id.to_string(), + } + } + + /// Create a Slack chat ID from a channel ID (e.g. `C01ABCDEF`). + pub fn slack(platform_id: &str) -> Self { + Self { + channel: ChannelKind::Slack, + platform_id: platform_id.to_string(), + } + } +} + /// Represents an allowed user in the config. Each platform has its own identity format. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] @@ -48,6 +74,29 @@ pub struct InboundMessage { pub context: MessageContext, } +impl InboundMessage { + /// Stamp an inbound message with routing context. Clones the `Arc`s for the router. + pub fn new( + chat_id: ChatId, + user_id: String, + text: String, + workspace: &Arc>, + provider: &Arc, + output_config: &Arc, + ) -> Self { + Self { + chat_id, + user_id, + text, + context: MessageContext { + workspace: Arc::clone(workspace), + provider: Arc::clone(provider), + output_config: Arc::clone(output_config), + }, + } + } +} + /// Routing context attached by the channel listener. Carries everything the router /// needs to execute and respond — no lookup tables required. ///