From 6a90aa08dfd63d48538b148d68e7a2c797377365 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:41:06 -0700 Subject: [PATCH] fix(channels): key router on user, not channel (Discord/Slack) Discord and Slack adapters set sender.platform_id to the channel/conversation ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..) was matching peer_id bindings against the channel ID and never finding the user-keyed binding. The sender_user_id() helper already existed but only the rate-limit/authz paths used it; the routing reads did not. Read-path fix: - discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"] - bridge.rs: route the text and audio paths through sender_user_id(message) - bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command resolves (/new, /compact, /model, /stop, /usage, /think) also key on user. Tests updated for the new signature. Write-path follow-up (set_user_default + broadcast routing) deferred to a separate commit so this change can be validated in isolation. --- crates/openfang-channels/src/bridge.rs | 67 ++++++++++++++++++------- crates/openfang-channels/src/discord.rs | 3 ++ crates/openfang-channels/src/slack.rs | 3 ++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 522f4b67..6fe0f426 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -797,7 +797,15 @@ async fn dispatch_message( // Handle commands first (early return) if let ChannelContent::Command { ref name, ref args } = message.content { - let result = handle_command(name, args, handle, router, &message.sender).await; + let result = handle_command( + name, + args, + handle, + router, + &message.sender, + sender_user_id(message), + ) + .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -881,7 +889,15 @@ async fn dispatch_message( }; if is_channel_command(cmd) { - let result = handle_command(cmd, &args, handle, router, &message.sender).await; + let result = handle_command( + cmd, + &args, + handle, + router, + &message.sender, + sender_user_id(message), + ) + .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -958,10 +974,12 @@ async fn dispatch_message( } } - // Route to agent (standard path) + // Route to agent (standard path). + // Use sender_user_id() so user-keyed bindings (peer_id) match for adapters like + // Discord/Slack where sender.platform_id is the channel ID, not the user ID. let agent_id = router.resolve( &message.channel, - &message.sender.platform_id, + sender_user_id(message), message.sender.openfang_user.as_deref(), ); @@ -1399,10 +1417,11 @@ async fn dispatch_with_blocks( lifecycle_reactions: bool, prefix_style: PrefixStyle, ) { - // Route to agent (same logic as text path) + // Route to agent (same logic as text path). + // Use sender_user_id() so user-keyed bindings match for Discord/Slack. let agent_id = router.resolve( &message.channel, - &message.sender.platform_id, + sender_user_id(message), message.sender.openfang_user.as_deref(), ); @@ -1609,12 +1628,19 @@ async fn dispatch_with_blocks( } /// Handle a bot command (returns the response text). +/// +/// `user_id` is the platform user ID (e.g. Discord author ID, Slack user ID). +/// For adapters that set `sender.platform_id` to the channel/conversation ID +/// (Discord, Slack), callers must pass `sender_user_id(message)` here so that +/// per-user agent routing works correctly. For adapters where platform_id is +/// already the user (CLI, Telegram DM), the two are equivalent. async fn handle_command( name: &str, args: &[String], handle: &Arc, router: &Arc, sender: &ChannelUser, + user_id: &str, ) -> String { // Canonicalise through the unified command registry: aliases resolve to // their canonical name and matching is case-insensitive. If the command @@ -1690,7 +1716,7 @@ async fn handle_command( // Need to resolve the user's current agent let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1704,7 +1730,7 @@ async fn handle_command( "compact" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1718,7 +1744,7 @@ async fn handle_command( "model" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1742,7 +1768,7 @@ async fn handle_command( "stop" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1756,7 +1782,7 @@ async fn handle_command( "usage" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1770,7 +1796,7 @@ async fn handle_command( "think" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - &sender.platform_id, + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1939,10 +1965,10 @@ mod tests { openfang_user: None, }; - let result = handle_command("agents", &[], &handle, &router, &sender).await; + let result = handle_command("agents", &[], &handle, &router, &sender, "user1").await; assert!(result.contains("coder")); - let result = handle_command("help", &[], &handle, &router, &sender).await; + let result = handle_command("help", &[], &handle, &router, &sender, "user1").await; assert!(result.contains("/agents")); } @@ -1960,8 +1986,15 @@ mod tests { }; // Select existing agent - let result = - handle_command("agent", &["coder".to_string()], &handle, &router, &sender).await; + let result = handle_command( + "agent", + &["coder".to_string()], + &handle, + &router, + &sender, + "user1", + ) + .await; assert!(result.contains("Now talking to agent: coder")); // Verify router was updated @@ -1982,7 +2015,7 @@ mod tests { openfang_user: None, }; - let result = handle_command("agent", &[], &handle, &router, &sender).await; + let result = handle_command("agent", &[], &handle, &router, &sender, "user1").await; assert!(result.contains("Usage: /agent ")); assert!(result.contains("coder")); } diff --git a/crates/openfang-channels/src/discord.rs b/crates/openfang-channels/src/discord.rs index cffa63a8..7d43e53f 100644 --- a/crates/openfang-channels/src/discord.rs +++ b/crates/openfang-channels/src/discord.rs @@ -636,6 +636,9 @@ async fn parse_discord_message( if was_mentioned { metadata.insert("was_mentioned".to_string(), serde_json::json!(true)); } + // Stash the Discord author ID so the router can key bindings on user, not channel. + // (`sender.platform_id` below is the channel ID, used for the send path.) + metadata.insert("sender_user_id".to_string(), serde_json::json!(author_id)); Some(ChannelMessage { channel: ChannelType::Discord, diff --git a/crates/openfang-channels/src/slack.rs b/crates/openfang-channels/src/slack.rs index f39d5e70..b4e81ee6 100644 --- a/crates/openfang-channels/src/slack.rs +++ b/crates/openfang-channels/src/slack.rs @@ -501,6 +501,9 @@ async fn parse_slack_event( // Check if the bot was @-mentioned (for group_policy = "mention_only") let mut metadata = HashMap::new(); + // Stash the Slack user ID so the router can key bindings on user, not channel. + // (`sender.platform_id` below is the channel ID, used for the send path.) + metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id)); if event_type == "app_mention" { metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true)); }