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.
This commit is contained in:
Ben Hoverter
2026-04-26 12:41:06 -07:00
parent e6bab993ae
commit 6a90aa08df
3 changed files with 56 additions and 17 deletions
+50 -17
View File
@@ -797,7 +797,15 @@ async fn dispatch_message(
// Handle commands first (early return) // Handle commands first (early return)
if let ChannelContent::Command { ref name, ref args } = message.content { 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; send_response(adapter, &message.sender, result, thread_id, output_format).await;
return; return;
} }
@@ -881,7 +889,15 @@ async fn dispatch_message(
}; };
if is_channel_command(cmd) { 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; send_response(adapter, &message.sender, result, thread_id, output_format).await;
return; 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( let agent_id = router.resolve(
&message.channel, &message.channel,
&message.sender.platform_id, sender_user_id(message),
message.sender.openfang_user.as_deref(), message.sender.openfang_user.as_deref(),
); );
@@ -1399,10 +1417,11 @@ async fn dispatch_with_blocks(
lifecycle_reactions: bool, lifecycle_reactions: bool,
prefix_style: PrefixStyle, 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( let agent_id = router.resolve(
&message.channel, &message.channel,
&message.sender.platform_id, sender_user_id(message),
message.sender.openfang_user.as_deref(), message.sender.openfang_user.as_deref(),
); );
@@ -1609,12 +1628,19 @@ async fn dispatch_with_blocks(
} }
/// Handle a bot command (returns the response text). /// 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( async fn handle_command(
name: &str, name: &str,
args: &[String], args: &[String],
handle: &Arc<dyn ChannelBridgeHandle>, handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>, router: &Arc<AgentRouter>,
sender: &ChannelUser, sender: &ChannelUser,
user_id: &str,
) -> String { ) -> String {
// Canonicalise through the unified command registry: aliases resolve to // Canonicalise through the unified command registry: aliases resolve to
// their canonical name and matching is case-insensitive. If the command // 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 // Need to resolve the user's current agent
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1704,7 +1730,7 @@ async fn handle_command(
"compact" => { "compact" => {
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1718,7 +1744,7 @@ async fn handle_command(
"model" => { "model" => {
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1742,7 +1768,7 @@ async fn handle_command(
"stop" => { "stop" => {
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1756,7 +1782,7 @@ async fn handle_command(
"usage" => { "usage" => {
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1770,7 +1796,7 @@ async fn handle_command(
"think" => { "think" => {
let agent_id = router.resolve( let agent_id = router.resolve(
&crate::types::ChannelType::CLI, &crate::types::ChannelType::CLI,
&sender.platform_id, user_id,
sender.openfang_user.as_deref(), sender.openfang_user.as_deref(),
); );
match agent_id { match agent_id {
@@ -1939,10 +1965,10 @@ mod tests {
openfang_user: None, 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")); 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")); assert!(result.contains("/agents"));
} }
@@ -1960,8 +1986,15 @@ mod tests {
}; };
// Select existing agent // Select existing agent
let result = let result = handle_command(
handle_command("agent", &["coder".to_string()], &handle, &router, &sender).await; "agent",
&["coder".to_string()],
&handle,
&router,
&sender,
"user1",
)
.await;
assert!(result.contains("Now talking to agent: coder")); assert!(result.contains("Now talking to agent: coder"));
// Verify router was updated // Verify router was updated
@@ -1982,7 +2015,7 @@ mod tests {
openfang_user: None, 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 <name>")); assert!(result.contains("Usage: /agent <name>"));
assert!(result.contains("coder")); assert!(result.contains("coder"));
} }
+3
View File
@@ -636,6 +636,9 @@ async fn parse_discord_message(
if was_mentioned { if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true)); 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 { Some(ChannelMessage {
channel: ChannelType::Discord, channel: ChannelType::Discord,
+3
View File
@@ -501,6 +501,9 @@ async fn parse_slack_event(
// Check if the bot was @-mentioned (for group_policy = "mention_only") // Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new(); 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" { if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true)); metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
} }