diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 2590ac2a1..414302f26 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -453,6 +453,16 @@ pub struct GatewayConfig { pub allowed_channels: Vec, #[serde(default)] pub allowed_users: Vec, + /// Allow messages from bots. Default: false. + /// NOTE: Intentionally `bool` (not `AllowBots` enum) — the gateway adapter + /// only needs on/off since @mention gating is handled separately by + /// `bot_username` + `should_skip_event`. Discord/Slack use `AllowBots` because + /// their adapters embed mention-mode logic internally. + #[serde(default)] + pub allow_bot_messages: bool, + /// Bot IDs that bypass the bot filter even when allow_bot_messages is false. + #[serde(default)] + pub trusted_bot_ids: Vec, /// Enable streaming (typewriter) mode — requires gateway platform to support message editing. #[serde(default)] pub streaming: bool, diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index a893cf2ed..6495b1b99 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -40,6 +40,48 @@ fn platform_acks_writes(platform: &str) -> bool { EDIT_RESPONSE_PLATFORMS.contains(&platform) } +/// Shared filter parameters for gateway event gating. +/// Used by both `run_gateway_adapter` (WebSocket) and `process_gateway_event` (unified). +struct EventFilterParams<'a> { + allow_all_channels: bool, + allowed_channels: &'a HashSet, + allow_all_users: bool, + allowed_users: &'a HashSet, + allow_bot_messages: bool, + trusted_bot_ids: &'a HashSet, + bot_username: Option<&'a str>, +} + +/// Returns `true` if the event should be skipped (filtered out). +fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { + // Bot filter + if event.sender.is_bot && !filter.allow_bot_messages && !filter.trusted_bot_ids.contains(&event.sender.id) { + tracing::info!(sender = %event.sender.id, "gateway: bot not in trusted_bot_ids, skipping"); + return true; + } + // Channel allowlist + if !filter.allow_all_channels && !filter.allowed_channels.contains(&event.channel.id) { + tracing::info!(channel = %event.channel.id, "gateway: channel not in allowed_channels, skipping"); + return true; + } + // User allowlist + if !filter.allow_all_users && !filter.allowed_users.contains(&event.sender.id) { + tracing::info!(sender = %event.sender.id, "gateway: user not in allowed_users, skipping"); + return true; + } + // @mention gating: in groups, only respond if bot is mentioned + let is_group = event.channel.channel_type == "group" || event.channel.channel_type == "supergroup"; + let in_thread = event.channel.thread_id.is_some(); + if is_group && !in_thread { + if let Some(bot_name) = filter.bot_username { + if !event.mentions.iter().any(|m| m == bot_name) { + return true; + } + } + } + false +} + // --- Gateway event/reply schemas (mirrors gateway service) --- #[derive(Clone, Debug, Deserialize)] @@ -677,6 +719,8 @@ pub struct GatewayParams { pub allowed_channels: Vec, pub allow_all_users: bool, pub allowed_users: Vec, + pub allow_bot_messages: bool, + pub trusted_bot_ids: Vec, pub streaming: bool, pub streaming_placeholder: bool, pub stt: crate::config::SttConfig, @@ -694,9 +738,11 @@ pub async fn run_gateway_adapter( let gateway_url = params.url; let bot_username = params.bot_username; let allow_all_channels = params.allow_all_channels; - let allowed_channels = params.allowed_channels; + let allowed_channels: HashSet = params.allowed_channels.into_iter().collect(); let allow_all_users = params.allow_all_users; - let allowed_users = params.allowed_users; + let allowed_users: HashSet = params.allowed_users.into_iter().collect(); + let allow_bot_messages = params.allow_bot_messages; + let trusted_bot_ids: HashSet = params.trusted_bot_ids.into_iter().collect(); let streaming = params.streaming; let streaming_placeholder = params.streaming_placeholder; let stt_config = params.stt; @@ -753,6 +799,17 @@ pub async fn run_gateway_adapter( let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); + // Hoist filter params outside loop — all fields are loop-invariant + let filter = EventFilterParams { + allow_all_channels, + allowed_channels: &allowed_channels, + allow_all_users, + allowed_users: &allowed_users, + allow_bot_messages, + trusted_bot_ids: &trusted_bot_ids, + bot_username: bot_username.as_deref(), + }; + loop { tokio::select! { msg = ws_rx.next() => { @@ -772,41 +829,10 @@ pub async fn run_gateway_adapter( match serde_json::from_str::(text_str) { Ok(event) => { - // TODO: gateway adapters (feishu) do their own bot filtering - // via AllowBots + trusted_bot_ids, but Telegram does not. - // When Feishu lifts the bot-to-bot delivery restriction, - // this guard needs to become adapter-aware (e.g. a field on - // GatewayEvent indicating the adapter already filtered bots). - if event.sender.is_bot { - continue; - } - - // Channel allowlist gate - if !allow_all_channels && !allowed_channels.contains(&event.channel.id) { - info!(channel = %event.channel.id, "gateway: channel not in allowed_channels, skipping"); + if should_skip_event(&event, &filter) { continue; } - // User allowlist gate - if !allow_all_users && !allowed_users.contains(&event.sender.id) { - info!(sender = %event.sender.id, "gateway: user not in allowed_users, skipping"); - continue; - } - - // @mention gating: in groups, only respond if bot is mentioned - // DMs (private) and thread replies always pass through - let is_group = event.channel.channel_type == "group" - || event.channel.channel_type == "supergroup"; - let in_thread = event.channel.thread_id.is_some(); - if is_group && !in_thread { - if let Some(ref bot_name) = bot_username { - let mentioned = event.mentions.iter().any(|m| m == bot_name); - if !mentioned { - continue; // skip non-mentioned group messages - } - } - } - info!( platform = %event.platform, sender = %event.sender.name, @@ -894,6 +920,11 @@ pub async fn run_gateway_adapter( .decode(&att.data) .map_err(|e| e.to_string()) } else { + tracing::warn!( + filename = %att.filename, + mime = %att.mime_type, + "gateway: attachment has no path or data, skipping" + ); Err("no path or data".into()) }; @@ -1009,7 +1040,7 @@ pub async fn run_gateway_adapter( match adapter.create_thread(&channel, &trigger_msg, &title).await { Ok(tc) => tc, Err(e) => { - warn!("create_thread failed, using channel: {e}"); + warn!("create_thread failed, replying in channel: {e}"); channel.clone() } } @@ -1097,6 +1128,8 @@ pub struct GatewayEventContext { pub allowed_channels: HashSet, pub allow_all_users: bool, pub allowed_users: HashSet, + pub allow_bot_messages: bool, + pub trusted_bot_ids: HashSet, pub bot_username: Option, pub stt_config: crate::config::SttConfig, } @@ -1114,36 +1147,20 @@ pub async fn process_gateway_event( let event: GatewayEvent = serde_json::from_str(event_json) .map_err(|e| anyhow::anyhow!("invalid gateway event JSON: {e}"))?; - // Bot filter - if event.sender.is_bot { - return Ok(false); - } - - // Channel allowlist gate - if !ctx.allow_all_channels && !ctx.allowed_channels.contains(&event.channel.id) { - tracing::info!(channel = %event.channel.id, "gateway: channel not in allowed_channels, skipping"); - return Ok(false); - } - - // User allowlist gate - if !ctx.allow_all_users && !ctx.allowed_users.contains(&event.sender.id) { - tracing::info!(sender = %event.sender.id, "gateway: user not in allowed_users, skipping"); + // Shared filter logic + let filter = EventFilterParams { + allow_all_channels: ctx.allow_all_channels, + allowed_channels: &ctx.allowed_channels, + allow_all_users: ctx.allow_all_users, + allowed_users: &ctx.allowed_users, + allow_bot_messages: ctx.allow_bot_messages, + trusted_bot_ids: &ctx.trusted_bot_ids, + bot_username: ctx.bot_username.as_deref(), + }; + if should_skip_event(&event, &filter) { return Ok(false); } - // @mention gating: in groups, only respond if bot is mentioned - let is_group = event.channel.channel_type == "group" - || event.channel.channel_type == "supergroup"; - let in_thread = event.channel.thread_id.is_some(); - if is_group && !in_thread { - if let Some(ref bot_name) = ctx.bot_username { - let mentioned = event.mentions.iter().any(|m| m == bot_name); - if !mentioned { - return Ok(false); - } - } - } - tracing::info!( platform = %event.platform, sender = %event.sender.name, @@ -1205,26 +1222,41 @@ pub async fn process_gateway_event( .decode(&att.data) .map_err(|e| e.to_string()) } else { + tracing::warn!( + filename = %att.filename, + mime = %att.mime_type, + "gateway: attachment has no path or data, skipping" + ); Err("no path or data".into()) }; match att.attachment_type.as_str() { "image" => { - if let Ok(bytes) = bytes_result { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - extra_blocks.push(ContentBlock::Image { - media_type: att.mime_type.clone(), - data: b64, - }); + match bytes_result { + Ok(bytes) => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + extra_blocks.push(ContentBlock::Image { + media_type: att.mime_type.clone(), + data: b64, + }); + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway image read failed"); + } } } "text_file" => { - if let Ok(bytes) = bytes_result { - let text = String::from_utf8_lossy(&bytes); - extra_blocks.push(ContentBlock::Text { - text: format!("```{}\n{}\n```", att.filename, text), - }); + match bytes_result { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes); + extra_blocks.push(ContentBlock::Text { + text: format!("```{}\n{}\n```", att.filename, text), + }); + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway text_file read failed"); + } } } "audio" if ctx.stt_config.enabled => { @@ -1307,7 +1339,7 @@ pub async fn process_gateway_event( match adapter.create_thread(&channel, &trigger_msg, &title).await { Ok(tc) => tc, Err(e) => { - tracing::warn!("create_thread failed, using channel: {e}"); + tracing::warn!("create_thread failed, replying in channel: {e}"); channel.clone() } } @@ -1357,3 +1389,132 @@ fn format_size(n: u64) -> String { format!("{} B", n) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn make_event(is_bot: bool, sender_id: &str, channel_id: &str, channel_type: &str, thread_id: Option<&str>, mentions: Vec<&str>) -> GatewayEvent { + serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.event.v1", + "event_id": "evt1", + "timestamp": "", + "platform": "test", + "channel": { "id": channel_id, "type": channel_type, "thread_id": thread_id }, + "sender": { "id": sender_id, "name": "user", "display_name": "User", "is_bot": is_bot }, + "content": { "type": "text", "text": "hello" }, + "mentions": mentions, + "message_id": "msg1" + })).unwrap() + } + + fn default_filter<'a>(allowed_channels: &'a HashSet, allowed_users: &'a HashSet, trusted_bot_ids: &'a HashSet) -> EventFilterParams<'a> { + EventFilterParams { + allow_all_channels: true, + allowed_channels, + allow_all_users: true, + allowed_users, + allow_bot_messages: false, + trusted_bot_ids, + bot_username: None, + } + } + + #[test] + fn bot_blocked_by_default() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let filter = default_filter(&ch, &us, &tb); + let event = make_event(true, "bot1", "ch1", "dm", None, vec![]); + assert!(should_skip_event(&event, &filter)); + } + + #[test] + fn trusted_bot_passes() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb: HashSet = ["bot1".into()].into(); + let filter = default_filter(&ch, &us, &tb); + let event = make_event(true, "bot1", "ch1", "dm", None, vec![]); + assert!(!should_skip_event(&event, &filter)); + } + + #[test] + fn all_bots_allowed() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.allow_bot_messages = true; + let event = make_event(true, "bot1", "ch1", "dm", None, vec![]); + assert!(!should_skip_event(&event, &filter)); + } + + #[test] + fn channel_allowlist_blocks() { + let ch: HashSet = ["allowed_ch".into()].into(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.allow_all_channels = false; + let event = make_event(false, "u1", "other_ch", "dm", None, vec![]); + assert!(should_skip_event(&event, &filter)); + } + + #[test] + fn channel_allowlist_passes() { + let ch: HashSet = ["ch1".into()].into(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.allow_all_channels = false; + let event = make_event(false, "u1", "ch1", "dm", None, vec![]); + assert!(!should_skip_event(&event, &filter)); + } + + #[test] + fn user_allowlist_blocks() { + let ch = HashSet::new(); + let us: HashSet = ["allowed_user".into()].into(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.allow_all_users = false; + let event = make_event(false, "other_user", "ch1", "dm", None, vec![]); + assert!(should_skip_event(&event, &filter)); + } + + #[test] + fn group_without_mention_skipped() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.bot_username = Some("mybot"); + let event = make_event(false, "u1", "ch1", "group", None, vec![]); + assert!(should_skip_event(&event, &filter)); + } + + #[test] + fn group_with_mention_passes() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.bot_username = Some("mybot"); + let event = make_event(false, "u1", "ch1", "group", None, vec!["mybot"]); + assert!(!should_skip_event(&event, &filter)); + } + + #[test] + fn thread_in_group_bypasses_mention_gating() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let mut filter = default_filter(&ch, &us, &tb); + filter.bot_username = Some("mybot"); + let event = make_event(false, "u1", "ch1", "group", Some("thread1"), vec![]); + assert!(!should_skip_event(&event, &filter)); + } +} diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index d9ed5b498..8a8e93a93 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -46,6 +46,107 @@ pub struct AppState { pub client: reqwest::Client, } + +impl AppState { + /// Build AppState from environment variables. + /// Initializes all platform adapters based on available env vars. + /// `ws_token` is passed separately (only needed for standalone gateway mode). + pub fn from_env(event_tx: broadcast::Sender, ws_token: Option) -> Self { + use tracing::{info, warn}; + + // Telegram + let telegram_bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok(); + let telegram_secret_token = std::env::var("TELEGRAM_SECRET_TOKEN").ok(); + let telegram_rich_messages = std::env::var("TELEGRAM_RICH_MESSAGES") + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(true); + + // LINE + let line_channel_secret = std::env::var("LINE_CHANNEL_SECRET").ok(); + let line_access_token = std::env::var("LINE_CHANNEL_ACCESS_TOKEN").ok(); + + // Teams + #[cfg(feature = "teams")] + let teams = adapters::teams::TeamsConfig::from_env().map(|config| { + info!("teams adapter configured"); + adapters::teams::TeamsAdapter::new(config) + }); + + // Feishu + #[cfg(feature = "feishu")] + let feishu = adapters::feishu::FeishuConfig::from_env() + .map(adapters::feishu::FeishuAdapter::new); + + // Google Chat + #[cfg(feature = "googlechat")] + let google_chat = { + let enabled = std::env::var("GOOGLE_CHAT_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + if enabled { + let token_cache = std::env::var("GOOGLE_CHAT_SA_KEY_JSON") + .ok() + .or_else(|| { + std::env::var("GOOGLE_CHAT_SA_KEY_FILE") + .ok() + .and_then(|path| { + std::fs::read_to_string(&path).map_err(|e| { + warn!("failed to read GOOGLE_CHAT_SA_KEY_FILE '{}': {e}", path); + }).ok() + }) + }) + .and_then(|json| { + adapters::googlechat::GoogleChatTokenCache::new(&json) + .map_err(|e| warn!("googlechat SA key error: {e}")) + .ok() + }); + let access_token = std::env::var("GOOGLE_CHAT_ACCESS_TOKEN").ok(); + let jwt_verifier = std::env::var("GOOGLE_CHAT_AUDIENCE").ok().map(|aud| { + info!("googlechat JWT verification enabled (audience={aud})"); + adapters::googlechat::GoogleChatJwtVerifier::new(aud) + }); + Some(adapters::googlechat::GoogleChatAdapter::new( + token_cache, access_token, jwt_verifier, + )) + } else { + None + } + }; + + // WeCom + #[cfg(feature = "wecom")] + let wecom = adapters::wecom::WecomConfig::from_env() + .map(adapters::wecom::WecomAdapter::new); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("HTTP client must build"); + + Self { + telegram_bot_token, + telegram_secret_token, + telegram_rich_messages, + line_channel_secret, + line_access_token, + #[cfg(feature = "teams")] + teams, + teams_service_urls: Mutex::new(HashMap::new()), + #[cfg(feature = "feishu")] + feishu, + #[cfg(feature = "googlechat")] + google_chat, + #[cfg(feature = "wecom")] + wecom, + ws_token, + event_tx, + reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), + line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), + client, + } + } +} + // --- Public serve() entry point --- /// Configuration for the standalone gateway server. @@ -197,7 +298,11 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { .or_else(|| { std::env::var("GOOGLE_CHAT_SA_KEY_FILE") .ok() - .and_then(|path| std::fs::read_to_string(&path).ok()) + .and_then(|path| { + std::fs::read_to_string(&path).map_err(|e| { + warn!("failed to read GOOGLE_CHAT_SA_KEY_FILE '{}': {e}", path); + }).ok() + }) }) .and_then(|json| { adapters::googlechat::GoogleChatTokenCache::new(&json) diff --git a/docs/config-reference.md b/docs/config-reference.md index d9abcb78e..1db760cc7 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -84,6 +84,8 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl | `allowed_channels` | string[] | `[]` | Chat/group IDs to allow. Only checked when `allow_all_channels` resolves to false. | | `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. | | `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. | +| `allow_bot_messages` | bool | `false` | Allow messages from bots. Unlike Discord/Slack (which use an enum with `"off"`/`"mentions"`/`"all"`), the gateway adapter uses a simple boolean: `true` = allow all bots, `false` = block (unless in `trusted_bot_ids`). | +| `trusted_bot_ids` | string[] | `[]` | Bot IDs that bypass the bot filter even when `allow_bot_messages = false`. | | `streaming` | bool | `false` | Enable streaming (typewriter) mode — requires the gateway platform to support message editing. | | `streaming_placeholder` | bool | `true` | Show "…" placeholder at streaming start. Set `false` for platforms using drafts (e.g. Telegram Rich Messages). | | `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | @@ -554,3 +556,44 @@ bot_token = "${DISCORD_BOT_TOKEN}" ``` Undefined variables resolve to an empty string. + +--- + +## Unified Mode Environment Variables + +When running with `BUILD_MODE=unified`, the binary embeds a webhook server for gateway platforms. These env vars control its behavior: + +### Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `GATEWAY_LISTEN` | `0.0.0.0:8080` | Bind address for the embedded webhook server | + +### Security Gating + +| Variable | Default | Description | +|----------|---------|-------------| +| `GATEWAY_ALLOW_ALL_CHANNELS` | `true` | Accept events from any channel. **Set to `false` in production** and use `GATEWAY_ALLOWED_CHANNELS`. | +| `GATEWAY_ALLOWED_CHANNELS` | _(empty)_ | Comma-separated channel IDs to allow (when `GATEWAY_ALLOW_ALL_CHANNELS=false`) | +| `GATEWAY_ALLOW_ALL_USERS` | `true` | Accept events from any user. **Set to `false` in production** and use `GATEWAY_ALLOWED_USERS`. | +| `GATEWAY_ALLOWED_USERS` | _(empty)_ | Comma-separated user IDs to allow (when `GATEWAY_ALLOW_ALL_USERS=false`) | +| `GATEWAY_ALLOW_BOT_MESSAGES` | `false` | Allow messages from all bots (for multi-agent scenarios) | +| `GATEWAY_TRUSTED_BOT_IDS` | _(empty)_ | Comma-separated bot IDs to allow even when `GATEWAY_ALLOW_BOT_MESSAGES=false` | +| `GATEWAY_BOT_USERNAME` | _(empty)_ | Bot's username for @mention detection in groups | + +### Platform Adapters + +Each platform is auto-enabled when its env vars are present: + +| Platform | Required Env Var | Optional | +|----------|-----------------|----------| +| Telegram | `TELEGRAM_BOT_TOKEN` | `TELEGRAM_SECRET_TOKEN`, `TELEGRAM_WEBHOOK_PATH`, `TELEGRAM_RICH_MESSAGES` | +| LINE | `LINE_CHANNEL_SECRET` | `LINE_CHANNEL_ACCESS_TOKEN` | +| Feishu | `FEISHU_APP_ID` | `FEISHU_WEBHOOK_PATH` | +| Google Chat | `GOOGLE_CHAT_ENABLED=true` | `GOOGLE_CHAT_SA_KEY_JSON`, `GOOGLE_CHAT_SA_KEY_FILE`, `GOOGLE_CHAT_ACCESS_TOKEN`, `GOOGLE_CHAT_AUDIENCE`, `GOOGLE_CHAT_WEBHOOK_PATH` | +| WeCom | `WECOM_CORP_ID` | _(see wecom config)_ | +| Teams | `TEAMS_APP_ID` | `TEAMS_WEBHOOK_PATH` | + +> ⚠️ **Production checklist**: Set `GATEWAY_ALLOW_ALL_CHANNELS=false` and `GATEWAY_ALLOW_ALL_USERS=false` with explicit allowlists. The defaults are permissive for development convenience. +> +> ⚠️ **Google Chat JWT**: When `GOOGLE_CHAT_AUDIENCE` is unset, webhook requests are **not** verified via JWT. Set this to your Google Chat app's project number or service account email in production to enable request authentication. If `GOOGLE_CHAT_SA_KEY_FILE` is set but the file cannot be read, the adapter starts without token authentication (warn logged). diff --git a/src/main.rs b/src/main.rs index 88d3462b4..117be1c36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -442,6 +442,8 @@ async fn main() -> anyhow::Result<()> { &gw_cfg.allowed_users, ), allowed_users: gw_cfg.allowed_users, + allow_bot_messages: gw_cfg.allow_bot_messages, + trusted_bot_ids: gw_cfg.trusted_bot_ids, streaming: gw_cfg.streaming, streaming_placeholder: gw_cfg.streaming_placeholder, stt: cfg.stt.clone(), @@ -502,38 +504,8 @@ async fn main() -> anyhow::Result<()> { // This reuses 100% of existing adapter code (signature verify, parsing, etc). let (event_tx, _) = tokio::sync::broadcast::channel::(256); - // Build gateway AppState (same as standalone binary) - let gw_state = Arc::new(openab_gateway::AppState { - telegram_bot_token: std::env::var("TELEGRAM_BOT_TOKEN").ok(), - telegram_secret_token: std::env::var("TELEGRAM_SECRET_TOKEN").ok(), - telegram_rich_messages: std::env::var("TELEGRAM_RICH_MESSAGES") - .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) - .unwrap_or(true), - line_channel_secret: std::env::var("LINE_CHANNEL_SECRET").ok(), - line_access_token: std::env::var("LINE_CHANNEL_ACCESS_TOKEN").ok(), - #[cfg(feature = "teams")] - teams: openab_gateway::adapters::teams::TeamsConfig::from_env() - .map(openab_gateway::adapters::teams::TeamsAdapter::new), - teams_service_urls: tokio::sync::Mutex::new(std::collections::HashMap::new()), - #[cfg(feature = "feishu")] - feishu: openab_gateway::adapters::feishu::FeishuConfig::from_env() - .map(openab_gateway::adapters::feishu::FeishuAdapter::new), - #[cfg(feature = "googlechat")] - google_chat: None, // TODO: wire up googlechat adapter config - #[cfg(feature = "wecom")] - wecom: openab_gateway::adapters::wecom::WecomConfig::from_env() - .map(openab_gateway::adapters::wecom::WecomAdapter::new), - ws_token: None, // not needed in unified mode (no WS endpoint) - event_tx: event_tx.clone(), - reply_token_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), - line_webhook_semaphore: Arc::new(tokio::sync::Semaphore::new( - openab_gateway::LINE_WEBHOOK_CONCURRENCY_MAX, - )), - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("HTTP client must build"), - }); + // Build gateway AppState from env vars (shared factory with standalone gateway) + let gw_state = Arc::new(openab_gateway::AppState::from_env(event_tx.clone(), None)); // Build axum router with platform webhook routes let mut app = axum::Router::new() @@ -577,6 +549,14 @@ async fn main() -> anyhow::Result<()> { app = app.route(&path, axum::routing::post(openab_gateway::adapters::teams::webhook)); } + #[cfg(feature = "googlechat")] + if gw_state.google_chat.is_some() { + let path = std::env::var("GOOGLE_CHAT_WEBHOOK_PATH") + .unwrap_or_else(|_| "/webhook/googlechat".into()); + info!(path = %path, "unified: googlechat adapter enabled"); + app = app.route(&path, axum::routing::post(openab_gateway::adapters::googlechat::webhook)); + } + let app = app.with_state(gw_state.clone()); // Bridge task: receive events from adapters via event_tx, dispatch to core @@ -607,6 +587,17 @@ async fn main() -> anyhow::Result<()> { .collect(); let gw_bot_username = std::env::var("GATEWAY_BOT_USERNAME").ok(); + let gw_allow_bot_messages = std::env::var("GATEWAY_ALLOW_BOT_MESSAGES") + .map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false); + let gw_trusted_bot_ids: std::collections::HashSet = + std::env::var("GATEWAY_TRUSTED_BOT_IDS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let event_ctx = Arc::new(GatewayEventContext { adapter: unified_adapter, dispatcher: unified_dispatcher, @@ -621,6 +612,8 @@ async fn main() -> anyhow::Result<()> { &gw_allowed_users.iter().cloned().collect::>(), ), allowed_users: gw_allowed_users, + allow_bot_messages: gw_allow_bot_messages, + trusted_bot_ids: gw_trusted_bot_ids, bot_username: gw_bot_username, stt_config: cfg.stt.clone(), });