From 55ce0fd8d80c7c20372f1ebafb02ca939078c6bb Mon Sep 17 00:00:00 2001 From: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Date: Wed, 6 May 2026 00:29:05 +0800 Subject: [PATCH 1/5] feat(gateway): feishu multibot-mentions mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AllowUsers enum (Involved/Mentions/MultibotMentions) controlled by FEISHU_ALLOW_USER_MESSAGES env var. In multibot-mentions mode, once another bot is @mentioned in a participated thread, require @mention for all bots — prevents multiple bots from responding simultaneously. Multibot detection strategy: - If FEISHU_TRUSTED_BOT_IDS configured: exact match - Otherwise: infer from allowed_users (mention not self and not in allowed_users → assumed to be another bot) - Only triggers in threads where bot has already participated This avoids requiring users to discover per-app open_ids for other bots. --- docs/feishu.md | 19 +++ gateway/src/adapters/feishu.rs | 213 ++++++++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 6 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index b1c70eb2..f1139d08 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -81,6 +81,7 @@ https://your-gateway-host/webhook/feishu | — | `FEISHU_TRUSTED_BOT_IDS` | — | Comma-separated open_id list of known bots | | — | `FEISHU_MAX_BOT_TURNS` | `20` | Max consecutive bot replies per channel before suppression | | — | `FEISHU_SESSION_TTL_HOURS` | `24` | How long the bot remembers thread participation (hours). After expiry, @mention is required again. | +| — | `FEISHU_ALLOW_USER_MESSAGES` | `involved` | Thread response mode: `involved` / `mentions` / `multibot-mentions`. See below. | | `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating | | `gateway.streaming` | — | `false` | Enable streaming (typewriter) mode | @@ -104,6 +105,24 @@ Once the bot replies in a thread (topic), it remembers that thread and responds - Participation is stored in memory. Gateway restart clears the cache; users need to @mention once to re-engage. - TTL controlled by `FEISHU_SESSION_TTL_HOURS` (default 24h). After expiry, @mention is required again. +### Multi-Bot Threads (multibot-mentions Mode) + +When `FEISHU_ALLOW_USER_MESSAGES=multibot-mentions`, the bot detects when another bot is @mentioned in a participated thread and reverts to requiring @mention — preventing all bots from responding simultaneously. + +| Mode | Behavior | +|------|----------| +| `involved` (default) | Bot responds in participated threads without @mention. All participated bots respond. | +| `multibot-mentions` | Same as `involved`, but once another bot is @mentioned in the thread, require @mention for all bots. | +| `mentions` | Always require @mention, even in participated threads. | + +**Multi-bot detection** (how the gateway identifies "another bot"): + +1. If `FEISHU_TRUSTED_BOT_IDS` is set → exact match against configured IDs +2. If only `FEISHU_ALLOWED_USERS` is set → any @mention that is not self and not in allowed_users is inferred as another bot (recommended, zero-config) +3. If neither is set → no multibot detection + +Note: Detection only triggers in threads where the bot has already participated. This prevents premature marking of threads the bot hasn't joined. + ## Security Notes - `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 286fa32b..306f725d 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -68,6 +68,20 @@ pub enum AllowBots { All, } +/// Controls when the bot responds without @mention in threads. +/// Mirrors Discord's `allow_user_messages` setting. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum AllowUsers { + /// Bot responds in threads it has participated in without @mention. + #[default] + Involved, + /// Always require @mention, even in participated threads. + Mentions, + /// Like Involved, but if another bot has also posted in the thread, + /// require @mention to avoid all bots responding. + MultibotMentions, +} + #[derive(Debug, Clone)] pub struct FeishuConfig { pub app_id: String, @@ -81,6 +95,7 @@ pub struct FeishuConfig { pub allowed_users: Vec, pub require_mention: bool, pub allow_bots: AllowBots, + pub allow_user_messages: AllowUsers, pub trusted_bot_ids: Vec, pub max_bot_turns: u32, pub dedupe_ttl_secs: u64, @@ -130,6 +145,16 @@ impl FeishuConfig { _ => AllowBots::Off, }; let trusted_bot_ids = parse_csv("FEISHU_TRUSTED_BOT_IDS"); + let allow_user_messages = match std::env::var("FEISHU_ALLOW_USER_MESSAGES") + .unwrap_or_else(|_| "involved".into()) + .to_lowercase() + .replace('-', "_") + .as_str() + { + "mentions" => AllowUsers::Mentions, + "multibot_mentions" => AllowUsers::MultibotMentions, + _ => AllowUsers::Involved, + }; let max_bot_turns = std::env::var("FEISHU_MAX_BOT_TURNS") .ok() .and_then(|v| v.parse().ok()) @@ -160,6 +185,7 @@ impl FeishuConfig { allowed_users, require_mention, allow_bots, + allow_user_messages, trusted_bot_ids, max_bot_turns, dedupe_ttl_secs, @@ -649,6 +675,9 @@ pub struct FeishuAdapter { /// When bot has replied in a thread, subsequent messages in that thread /// bypass @mention gating (like Discord's "involved" mode). pub participated_threads: Arc>>, + /// Positive-only cache: thread_id → first_seen for threads where other bots + /// have posted. Used by multibot-mentions mode to require @mention. + pub multibot_threads: Arc>>, pub client: reqwest::Client, } @@ -666,6 +695,7 @@ impl FeishuAdapter { name_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), bot_turns: Arc::new(std::sync::Mutex::new(HashMap::new())), participated_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), + multibot_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), client: reqwest::Client::new(), } } @@ -760,6 +790,7 @@ pub async fn start_websocket( let name_cache = adapter.name_cache.clone(); let bot_turns = adapter.bot_turns.clone(); let participated_threads = adapter.participated_threads.clone(); + let multibot_threads = adapter.multibot_threads.clone(); let handle = tokio::spawn(async move { let mut backoff_secs = 1u64; @@ -775,6 +806,7 @@ pub async fn start_websocket( &name_cache, &bot_turns, &participated_threads, + &multibot_threads, ) .await; @@ -816,6 +848,7 @@ async fn ws_connect_loop( name_cache: &Arc>>, bot_turns: &Arc>>, participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) -> anyhow::Result<()> { let api_base = config.api_base(); @@ -843,7 +876,7 @@ async fn ws_connect_loop( Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, participated_threads, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => { @@ -864,7 +897,7 @@ async fn ws_connect_loop( if let Ok(text) = String::from_utf8(payload.clone()) { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, participated_threads, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } } @@ -905,6 +938,7 @@ async fn handle_ws_message( client: &reqwest::Client, bot_turns: &Arc>>, participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) { let envelope: FeishuEventEnvelope = match serde_json::from_str(text) { Ok(e) => e, @@ -940,10 +974,86 @@ async fn handle_ws_message( let bot_id = bot_open_id_store.read().await; let bot_id_ref = bot_id.as_deref(); - // Check if the message is in a thread where bot has previously replied - let is_thread_participated = check_thread_participated( + // Check if the message is in a thread where bot has previously replied, + // respecting the allow_user_messages mode: + // - Involved (default): bypass @mention if participated + // - MultibotMentions: bypass only if participated AND no other bot in thread + // - Mentions: never bypass + let thread_id_for_check = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); + + // Early multibot detection (before participation check): if a message in a + // thread @mentions another bot, mark the thread as multibot immediately. + // Only check threads where this bot has participated — no point marking + // threads we haven't joined yet (we wouldn't respond anyway). + // Detection strategy: + // 1. If FEISHU_TRUSTED_BOT_IDS is configured → exact match (explicit) + // 2. Otherwise → infer: any @mention that is not self and not in allowed_users + // is assumed to be another bot. This works because in a controlled group, + // allowed_users lists the humans; anything else is likely a bot. + // This avoids requiring users to discover per-app open_ids for other bots. + let self_participated = check_thread_participated( &envelope, participated_threads, config.session_ttl_secs, ); + if let Some(tid) = thread_id_for_check { + if self_participated { + let mentions = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.mentions.as_ref()); + if let Some(mention_list) = mentions { + let bot_self_id = bot_id_ref.unwrap_or(""); + let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { + m.id.as_ref().and_then(|id| id.open_id.as_deref()) + }).collect(); + + let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { + mention_ids.iter().any(|oid| { + config.trusted_bot_ids.iter().any(|bid| bid == oid) + }) + } else if !config.allowed_users.is_empty() { + mention_ids.iter().any(|oid| { + *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) + }) + } else { + false + }; + + if mentions_other_bot { + info!(thread_id = %tid, "multibot thread detected via @mention"); + // Intentionally recover from poisoned mutex + let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache.entry(tid.to_string()).or_insert_with(Instant::now); + // Evict expired entries if over capacity + if cache.len() > PARTICIPATION_CACHE_MAX { + cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs); + } + } + } + } + } + + let is_thread_participated = match config.allow_user_messages { + AllowUsers::Mentions => false, + AllowUsers::Involved => self_participated, + AllowUsers::MultibotMentions => { + if !self_participated { + false + } else { + // Already participated; check if thread is multibot + thread_id_for_check + .map(|tid| { + let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs) + }) + .unwrap_or(true) // no thread_id → not multibot → allow + } + } + }; if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, is_thread_participated) { // Also dedupe by message_id @@ -967,6 +1077,8 @@ async fn handle_ws_message( ); return; } + // (Feishu doesn't push bot messages to other bots' WebSocket, + // so multibot detection is done via mentions instead — see below.) } else { // Human message resets bot turn counter turns.remove(channel_id.as_str()); @@ -2107,11 +2219,65 @@ pub async fn webhook( let bot_id = feishu.bot_open_id.read().await; let bot_id_ref = bot_id.as_deref(); - // Check participated threads for mention bypass - let is_thread_participated = check_thread_participated( + // Check participated threads for mention bypass (respects allow_user_messages mode) + let self_participated = check_thread_participated( &envelope, &feishu.participated_threads, feishu.config.session_ttl_secs, ); + // Early multibot detection (same logic as WebSocket path) + let thread_id_for_check = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); + + if let Some(tid) = thread_id_for_check { + if self_participated { + let mentions = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.mentions.as_ref()); + if let Some(mention_list) = mentions { + let bot_self_id = bot_id_ref.unwrap_or(""); + let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { + m.id.as_ref().and_then(|id| id.open_id.as_deref()) + }).collect(); + let mentions_other_bot = if !feishu.config.trusted_bot_ids.is_empty() { + mention_ids.iter().any(|oid| feishu.config.trusted_bot_ids.iter().any(|bid| bid == oid)) + } else if !feishu.config.allowed_users.is_empty() { + mention_ids.iter().any(|oid| *oid != bot_self_id && !feishu.config.allowed_users.iter().any(|u| u == oid)) + } else { + false + }; + if mentions_other_bot { + let mut cache = feishu.multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache.entry(tid.to_string()).or_insert_with(Instant::now); + if cache.len() > PARTICIPATION_CACHE_MAX { + cache.retain(|_, ts| ts.elapsed().as_secs() < feishu.config.session_ttl_secs); + } + } + } + } + } + + let is_thread_participated = match feishu.config.allow_user_messages { + AllowUsers::Mentions => false, + AllowUsers::Involved => self_participated, + AllowUsers::MultibotMentions => { + if !self_participated { + false + } else { + thread_id_for_check + .map(|tid| { + let cache = feishu.multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < feishu.config.session_ttl_secs) + }) + .unwrap_or(true) + } + } + }; + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, is_thread_participated) { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( @@ -2182,6 +2348,7 @@ mod tests { allowed_users: vec![], require_mention: true, allow_bots: AllowBots::Off, + allow_user_messages: AllowUsers::Involved, trusted_bot_ids: vec![], max_bot_turns: 20, dedupe_ttl_secs: 300, @@ -2777,4 +2944,38 @@ mod tests { // After eviction, should be roughly half assert!(cache.lock().unwrap().len() <= PARTICIPATION_CACHE_MAX); } + + // --- Multibot-mentions mode tests --- + + #[test] + fn multibot_mentions_mode_bypasses_when_single_bot() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // participated + no other bot → bypass (is_thread_participated=true) + let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); + assert!(result.is_some()); + } + + #[test] + fn multibot_mentions_mode_requires_mention_when_not_participated() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // not participated → require @mention (is_thread_participated=false) + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } + + #[test] + fn mentions_mode_never_bypasses() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::Mentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into()); + // Even with is_thread_participated=true, Mentions mode never bypasses + // (caller would pass false because Mentions mode always returns false) + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } } From 1ed114a0d02eb233f5d00856e4a1ee4a01f9eb52 Mon Sep 17 00:00:00 2001 From: masami-agent Date: Wed, 6 May 2026 14:04:38 +0000 Subject: [PATCH 2/5] refactor(gateway): extract detect_and_mark_multibot() helper Deduplicate the multibot detection block (~30 lines) that was repeated in both handle_ws_message and webhook(). Both now call a shared detect_and_mark_multibot() helper that handles: - Thread participation check - @mention-based other-bot detection (trusted IDs or inference) - Multibot cache marking with eviction - Computing is_thread_participated based on allow_user_messages mode Also update PARTICIPATION_CACHE_MAX comment to note it is intentionally shared between participated_threads and multibot_threads caches. --- gateway/src/adapters/feishu.rs | 222 +++++++++++++-------------------- 1 file changed, 90 insertions(+), 132 deletions(-) diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 306f725d..7f3fa2d3 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -979,81 +979,9 @@ async fn handle_ws_message( // - Involved (default): bypass @mention if participated // - MultibotMentions: bypass only if participated AND no other bot in thread // - Mentions: never bypass - let thread_id_for_check = envelope - .event - .as_ref() - .and_then(|e| e.message.as_ref()) - .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); - - // Early multibot detection (before participation check): if a message in a - // thread @mentions another bot, mark the thread as multibot immediately. - // Only check threads where this bot has participated — no point marking - // threads we haven't joined yet (we wouldn't respond anyway). - // Detection strategy: - // 1. If FEISHU_TRUSTED_BOT_IDS is configured → exact match (explicit) - // 2. Otherwise → infer: any @mention that is not self and not in allowed_users - // is assumed to be another bot. This works because in a controlled group, - // allowed_users lists the humans; anything else is likely a bot. - // This avoids requiring users to discover per-app open_ids for other bots. - let self_participated = check_thread_participated( - &envelope, participated_threads, config.session_ttl_secs, + let is_thread_participated = detect_and_mark_multibot( + &envelope, bot_id_ref, config, participated_threads, multibot_threads, ); - if let Some(tid) = thread_id_for_check { - if self_participated { - let mentions = envelope - .event - .as_ref() - .and_then(|e| e.message.as_ref()) - .and_then(|m| m.mentions.as_ref()); - if let Some(mention_list) = mentions { - let bot_self_id = bot_id_ref.unwrap_or(""); - let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { - m.id.as_ref().and_then(|id| id.open_id.as_deref()) - }).collect(); - - let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { - mention_ids.iter().any(|oid| { - config.trusted_bot_ids.iter().any(|bid| bid == oid) - }) - } else if !config.allowed_users.is_empty() { - mention_ids.iter().any(|oid| { - *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) - }) - } else { - false - }; - - if mentions_other_bot { - info!(thread_id = %tid, "multibot thread detected via @mention"); - // Intentionally recover from poisoned mutex - let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); - cache.entry(tid.to_string()).or_insert_with(Instant::now); - // Evict expired entries if over capacity - if cache.len() > PARTICIPATION_CACHE_MAX { - cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs); - } - } - } - } - } - - let is_thread_participated = match config.allow_user_messages { - AllowUsers::Mentions => false, - AllowUsers::Involved => self_participated, - AllowUsers::MultibotMentions => { - if !self_participated { - false - } else { - // Already participated; check if thread is multibot - thread_id_for_check - .map(|tid| { - let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); - !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs) - }) - .unwrap_or(true) // no thread_id → not multibot → allow - } - } - }; if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, is_thread_participated) { // Also dedupe by message_id @@ -1804,9 +1732,92 @@ fn check_thread_participated( .unwrap_or(false) } -/// Max entries in the participated_threads cache before eviction. +/// Max entries before eviction. Shared by both `participated_threads` and +/// `multibot_threads` caches — they have the same cardinality (one entry per +/// active thread) so a single limit is appropriate for both. const PARTICIPATION_CACHE_MAX: usize = 1000; +/// Detect if a message @mentions another bot in a participated thread, and if +/// so, mark the thread in the multibot cache. Returns the computed +/// `is_thread_participated` value respecting the `allow_user_messages` mode. +/// +/// This consolidates the duplicated multibot detection logic used by both the +/// WebSocket and webhook paths. +fn detect_and_mark_multibot( + envelope: &FeishuEventEnvelope, + bot_open_id: Option<&str>, + config: &FeishuConfig, + participated_threads: &Arc>>, + multibot_threads: &Arc>>, +) -> bool { + let self_participated = check_thread_participated( + envelope, participated_threads, config.session_ttl_secs, + ); + + let thread_id_for_check = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); + + // Early multibot detection: if a message in a participated thread @mentions + // another bot, mark the thread as multibot immediately. + if let Some(tid) = thread_id_for_check { + if self_participated { + let mentions = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.mentions.as_ref()); + if let Some(mention_list) = mentions { + let bot_self_id = bot_open_id.unwrap_or(""); + let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { + m.id.as_ref().and_then(|id| id.open_id.as_deref()) + }).collect(); + + let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { + mention_ids.iter().any(|oid| { + config.trusted_bot_ids.iter().any(|bid| bid == oid) + }) + } else if !config.allowed_users.is_empty() { + mention_ids.iter().any(|oid| { + *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) + }) + } else { + false + }; + + if mentions_other_bot { + info!(thread_id = %tid, "multibot thread detected via @mention"); + let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache.entry(tid.to_string()).or_insert_with(Instant::now); + if cache.len() > PARTICIPATION_CACHE_MAX { + cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs); + } + } + } + } + } + + // Compute is_thread_participated based on mode + match config.allow_user_messages { + AllowUsers::Mentions => false, + AllowUsers::Involved => self_participated, + AllowUsers::MultibotMentions => { + if !self_participated { + false + } else { + thread_id_for_check + .map(|tid| { + let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs) + }) + .unwrap_or(true) + } + } + } +} + /// Record that the bot has participated in a thread. Evicts oldest entries /// when the cache exceeds PARTICIPATION_CACHE_MAX. fn record_participation( @@ -2219,65 +2230,12 @@ pub async fn webhook( let bot_id = feishu.bot_open_id.read().await; let bot_id_ref = bot_id.as_deref(); - // Check participated threads for mention bypass (respects allow_user_messages mode) - let self_participated = check_thread_participated( - &envelope, &feishu.participated_threads, feishu.config.session_ttl_secs, + // Check participated threads and multibot detection for mention bypass + let is_thread_participated = detect_and_mark_multibot( + &envelope, bot_id_ref, &feishu.config, + &feishu.participated_threads, &feishu.multibot_threads, ); - // Early multibot detection (same logic as WebSocket path) - let thread_id_for_check = envelope - .event - .as_ref() - .and_then(|e| e.message.as_ref()) - .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); - - if let Some(tid) = thread_id_for_check { - if self_participated { - let mentions = envelope - .event - .as_ref() - .and_then(|e| e.message.as_ref()) - .and_then(|m| m.mentions.as_ref()); - if let Some(mention_list) = mentions { - let bot_self_id = bot_id_ref.unwrap_or(""); - let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { - m.id.as_ref().and_then(|id| id.open_id.as_deref()) - }).collect(); - let mentions_other_bot = if !feishu.config.trusted_bot_ids.is_empty() { - mention_ids.iter().any(|oid| feishu.config.trusted_bot_ids.iter().any(|bid| bid == oid)) - } else if !feishu.config.allowed_users.is_empty() { - mention_ids.iter().any(|oid| *oid != bot_self_id && !feishu.config.allowed_users.iter().any(|u| u == oid)) - } else { - false - }; - if mentions_other_bot { - let mut cache = feishu.multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); - cache.entry(tid.to_string()).or_insert_with(Instant::now); - if cache.len() > PARTICIPATION_CACHE_MAX { - cache.retain(|_, ts| ts.elapsed().as_secs() < feishu.config.session_ttl_secs); - } - } - } - } - } - - let is_thread_participated = match feishu.config.allow_user_messages { - AllowUsers::Mentions => false, - AllowUsers::Involved => self_participated, - AllowUsers::MultibotMentions => { - if !self_participated { - false - } else { - thread_id_for_check - .map(|tid| { - let cache = feishu.multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); - !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < feishu.config.session_ttl_secs) - }) - .unwrap_or(true) - } - } - }; - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, is_thread_participated) { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( From daaa9bc478e5d93fbbb7e805fc72ddd5a0b15888 Mon Sep 17 00:00:00 2001 From: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Date: Thu, 7 May 2026 07:46:42 +0800 Subject: [PATCH 3/5] refactor(gateway): address review nits on #746 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. session_ttl_secs doc comment: clarify conversion from FEISHU_SESSION_TTL_HOURS 2. Rename is_thread_participated → bypass_mention_gating in parse_message_event with doc comment explaining the parameter semantics --- gateway/src/adapters/feishu.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 7f3fa2d3..82e8688c 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -104,6 +104,7 @@ pub struct FeishuConfig { /// this are forgotten and require a fresh @mention to re-engage. /// Set to 0 (via FEISHU_SESSION_TTL_HOURS=0) to disable participation /// tracking entirely — all messages will require @mention. + /// Converted from `FEISHU_SESSION_TTL_HOURS` (user-facing, in hours) to seconds internally. pub session_ttl_secs: u64, } @@ -279,11 +280,16 @@ mod event_types { /// Parse a feishu im.message.receive_v1 event into a GatewayEvent. /// Returns None if the event should be skipped (unsupported type, bot message, etc). /// The Vec contains references to media that need async download. + /// + /// `bypass_mention_gating`: whether the bot should skip @mention requirement for this message. + /// This is the final computed result from mode-specific logic (detect_and_mark_multibot), + /// already accounting for the configured `allow_user_messages` mode. + /// Do NOT pass raw participation status here. pub fn parse_message_event( envelope: &FeishuEventEnvelope, bot_open_id: Option<&str>, config: &FeishuConfig, - is_thread_participated: bool, + bypass_mention_gating: bool, ) -> Option<(GatewayEvent, Vec)> { let _header = envelope.header.as_ref()?; let event = envelope.event.as_ref()?; @@ -454,7 +460,7 @@ mod event_types { // no @mention needed (like Discord's "involved" mode). let in_thread = thread_id.is_some(); if channel_type == "group" && !is_bot_sender && config.require_mention { - if !(in_thread && is_thread_participated) { + if !(in_thread && bypass_mention_gating) { if let Some(bot_id) = bot_open_id { let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); if !bot_mentioned { @@ -2911,7 +2917,7 @@ mod tests { cfg.allow_user_messages = AllowUsers::MultibotMentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); - // participated + no other bot → bypass (is_thread_participated=true) + // participated + no other bot → bypass_mention_gating=true let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); assert!(result.is_some()); } @@ -2922,7 +2928,7 @@ mod tests { cfg.allow_user_messages = AllowUsers::MultibotMentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); - // not participated → require @mention (is_thread_participated=false) + // not participated → bypass_mention_gating=false assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } @@ -2932,7 +2938,7 @@ mod tests { cfg.allow_user_messages = AllowUsers::Mentions; let mut env = make_envelope("group", "Hello", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into()); - // Even with is_thread_participated=true, Mentions mode never bypasses + // Even with bypass_mention_gating=true, Mentions mode never bypasses // (caller would pass false because Mentions mode always returns false) assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } From 99784e64c5d1e1d57967ce7eddb86e1502db7199 Mon Sep 17 00:00:00 2001 From: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Date: Fri, 8 May 2026 08:41:38 +0800 Subject: [PATCH 4/5] fix(gateway): add missing attachments field in googlechat tests Content struct gained an attachments field in #744 merge but googlechat test constructors were not updated. --- gateway/src/adapters/googlechat.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 68759e02..73787089 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -1371,6 +1371,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_123".into()), @@ -1413,6 +1414,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_fail".into()), @@ -1459,6 +1461,7 @@ mod tests { content: Content { content_type: "text".into(), text: "".into(), + attachments: vec![], }, command: None, request_id: Some("req_empty".into()), @@ -1502,6 +1505,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_multi_fail".into()), @@ -1535,6 +1539,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_notoken".into()), @@ -1579,6 +1584,7 @@ mod tests { content: Content { content_type: "text".into(), text: "updated text".into(), + attachments: vec![], }, command: Some("edit_message".into()), request_id: None, @@ -1620,6 +1626,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_multi".into()), @@ -1676,6 +1683,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_partial".into()), From 5e7f308f48db8d17a860d947b4b106714077865d Mon Sep 17 00:00:00 2001 From: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Date: Wed, 6 May 2026 23:58:02 +0800 Subject: [PATCH 5/5] feat(gateway): feishu voice message STT via gateway audio attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add msg_type=audio support to feishu adapter (parse, download, base64 encode) - Add MediaRef::Audio variant and download_feishu_audio() function - Add "audio" attachment type to core gateway handler (decode → stt::transcribe) - Pass SttConfig to gateway handler via GatewayParams - Update docs/feishu.md and docs/stt.md for multi-platform voice support Feishu voice messages (opus/ogg) are downloaded by the gateway, passed as base64-encoded audio attachments to core, and transcribed via the existing [stt] infrastructure (Groq Whisper by default). This is the first gateway platform to support audio — LINE/Telegram can reuse the core-side handler. Tested: 102 gateway tests + 197 core tests pass. E2E verified. --- docs/feishu.md | 1 + docs/stt.md | 6 +-- gateway/src/adapters/feishu.rs | 70 +++++++++++++++++++++++++++++++++- src/gateway.rs | 22 +++++++++++ src/main.rs | 1 + 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index f1139d08..c18f8dca 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -167,6 +167,7 @@ The gateway downloads and forwards image and text file attachments to the AI age | `text` | Text extracted, forwarded as prompt | | `image` | Image downloaded, resized (max 1200px), JPEG compressed, base64 encoded → `ContentBlock::Image` | | `file` | Text files only (`.txt`, `.py`, `.rs`, `.md`, `.json`, etc., max 512KB). Non-text files (`.pdf`, `.zip`, etc.) are silently ignored. | +| `audio` | Voice message downloaded (opus/ogg, max 25MB), base64 encoded, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. | | `post` | Rich text: text nodes extracted as prompt, `img` nodes downloaded as image attachments. This is the format Feishu uses when @mention + paste image in a group. | **Group chat limitation:** Feishu does not allow @mention and image upload in the same message. However, @mention + paste (Ctrl+V) an image works — Feishu sends this as a `post` message containing both the mention and the image. Direct image upload (via the attachment button) cannot include @mention, so the bot will not respond in groups. diff --git a/docs/stt.md b/docs/stt.md index 202f9678..5e76ff54 100644 --- a/docs/stt.md +++ b/docs/stt.md @@ -1,6 +1,6 @@ # Speech-to-Text (STT) for Voice Messages -openab can automatically transcribe Discord voice message attachments and forward the transcript to your ACP agent as text. +openab can automatically transcribe voice message attachments (Discord, Feishu, and other gateway platforms) and forward the transcript to your ACP agent as text. ## Quick Start @@ -24,7 +24,7 @@ api_key = "${GROQ_API_KEY}" ## How It Works ``` -Discord voice message (.ogg) +Voice message (Discord .ogg, Feishu opus/ogg, etc.) │ ▼ openab downloads the audio file @@ -170,6 +170,6 @@ When disabled, audio attachments are silently skipped with no impact on existing ## Technical Notes - openab sends `response_format=json` in the transcription request to ensure the response is always parseable JSON. Some local whisper servers default to plain text output without this parameter. -- The actual MIME type from the Discord attachment is passed through to the STT API (e.g. `audio/ogg`, `audio/mp4`, `audio/wav`). +- The actual MIME type from the platform attachment is passed through to the STT API (e.g. `audio/ogg` for Discord and Feishu voice messages, `audio/mp4`, `audio/wav`). - Environment variables in config values are expanded via `${VAR}` syntax (e.g. `api_key = "${GROQ_API_KEY}"`). - The `api_key` field is auto-detected from the `GROQ_API_KEY` environment variable when using the default Groq endpoint. If you set a custom `base_url` (e.g. local server), auto-detect is disabled to avoid leaking the Groq key to unrelated endpoints — you must set `api_key` explicitly. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 82e8688c..3ff26cc2 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -297,7 +297,7 @@ mod event_types { let sender = event.sender.as_ref()?; let msg_type = msg.message_type.as_deref().unwrap_or("text"); - if !matches!(msg_type, "text" | "image" | "file" | "post") { + if !matches!(msg_type, "text" | "image" | "file" | "post" | "audio") { return None; } // Skip bot messages with explicit sender_type @@ -385,6 +385,17 @@ mod event_types { }]; (String::new(), mentions.1, refs) } + "audio" => { + let file_key = content_json.get("file_key")?.as_str()?; + let mentions = extract_mentions( + "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, + ); + let refs = vec![MediaRef::Audio { + message_id: message_id.to_string(), + file_key: file_key.to_string(), + }]; + (String::new(), mentions.1, refs) + } "post" => { // Rich text: content is {"title":"...","content":[[{tag,text,...},{tag,image_key,...}]]} let mut texts = Vec::new(); @@ -1038,6 +1049,9 @@ async fn handle_ws_message( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); @@ -1343,6 +1357,7 @@ fn try_parse_link(chars: &[char], start: usize) -> Option<(String, String, usize pub enum MediaRef { Image { message_id: String, image_key: String }, File { message_id: String, file_key: String, file_name: String }, + Audio { message_id: String, file_key: String }, } const IMAGE_MAX_DIMENSION_PX: u32 = 1200; @@ -1497,6 +1512,56 @@ pub async fn download_feishu_file( }) } +const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit) + +/// Download a Feishu audio message by message_id + file_key → base64 Attachment. +pub async fn download_feishu_audio( + client: &reqwest::Client, + api_base: &str, + token: &str, + message_id: &str, + file_key: &str, +) -> Option { + let url = format!( + "{}/open-apis/im/v1/messages/{}/resources/{}?type=file", + api_base, message_id, file_key + ); + let resp = match client.get(&url).bearer_auth(token).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!(file_key, error = %e, "feishu audio download failed"); + return None; + } + }; + if !resp.status().is_success() { + tracing::warn!(file_key, status = %resp.status(), "feishu audio download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size, "feishu audio exceeds 25MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size = bytes.len(), "feishu audio exceeds 25MB limit"); + return None; + } + tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded"); + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(crate::schema::Attachment { + attachment_type: "audio".into(), + filename: format!("{}.ogg", file_key), + mime_type: "audio/ogg".into(), + data, + size: bytes.len() as u64, + }) +} + /// Send a post (rich text) message to a feishu chat_id. /// Returns the sent message_id on success, None on failure. /// When `reply_to` is Some(root_id), uses the reply API to stay in a thread. @@ -2263,6 +2328,9 @@ pub async fn webhook( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(&feishu.client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(&feishu.client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); diff --git a/src/gateway.rs b/src/gateway.rs index d8fa967c..633faf81 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -488,6 +488,7 @@ pub struct GatewayParams { pub allow_all_users: bool, pub allowed_users: Vec, pub streaming: bool, + pub stt: crate::config::SttConfig, } pub async fn run_gateway_adapter( @@ -506,6 +507,7 @@ pub async fn run_gateway_adapter( let allow_all_users = params.allow_all_users; let allowed_users = params.allowed_users; let streaming = params.streaming; + let stt_config = params.stt; let connect_url = match ¶ms.token { Some(token) => { @@ -676,6 +678,26 @@ pub async fn run_gateway_adapter( }); } } + "audio" => { + if stt_config.enabled { + use base64::Engine; + if let Ok(audio_bytes) = base64::engine::general_purpose::STANDARD.decode(&att.data) { + if let Some(transcript) = crate::stt::transcribe( + &crate::media::HTTP_CLIENT, + &stt_config, + audio_bytes, + att.filename.clone(), + &att.mime_type, + ).await { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + } else { + warn!(filename = %att.filename, "audio attachment base64 decode failed"); + } + } + } _ => {} } } diff --git a/src/main.rs b/src/main.rs index 706079b6..c2e5f41a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -298,6 +298,7 @@ async fn main() -> anyhow::Result<()> { ), allowed_users: gw_cfg.allowed_users, streaming: gw_cfg.streaming, + stt: cfg.stt.clone(), }; let gw_router = router.clone(); Some(tokio::spawn(async move {