Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn CliBackend>` 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<dyn ChannelProvider>` because polling closures need owned captures. A `&self` borrow doesn't live long enough.

### Error Handling
Expand Down Expand Up @@ -143,15 +143,14 @@ All four must pass before any commit.
1. Create `src/channel/<name>.rs` with a struct that implements `ChannelProvider` and `ChannelProviderFactory`.
2. Add a module-level `resolve_users(users: &[AllowedUser]) -> Result<HashSet<String>>` 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<WorkspaceHandle>`, `provider: self_arc.clone()`, `output_config: Arc::new(effective_output_config(global, channel_cfg))`.
7. Add `pub mod <name>;` 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/<name>_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 <name>;` 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/<name>_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

Expand Down
14 changes: 6 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()`.

Expand Down
2 changes: 1 addition & 1 deletion src/channel/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 7 additions & 13 deletions src/channel/slack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
22 changes: 8 additions & 14 deletions src/channel/telegram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
47 changes: 21 additions & 26 deletions src/channel/whatsapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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)
Expand All @@ -300,12 +300,7 @@ impl ChannelProvider for WhatsAppProvider {
let notice =
format!("[File `{name}` ({} bytes) — see CLI output]", content.len());
let safe = enforce_whatsapp_limit(&notice);
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)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/config_cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 6 additions & 21 deletions src/tests/session_test.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,23 @@
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);
}

#[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);
Expand All @@ -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);
Expand All @@ -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
}
49 changes: 49 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<RwLock<WorkspaceHandle>>,
provider: &Arc<dyn ChannelProvider>,
output_config: &Arc<OutputConfig>,
) -> 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.
///
Expand Down
Loading