diff --git a/config.toml.example b/config.toml.example index 8ac355ac..74a4ac8f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -31,6 +31,18 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # once another bot has posted in the thread # max_bot_turns = 20 # soft cap on consecutive bot turns per thread (human msg resets) +# [gateway] +# url = "ws://openab-gateway:8080/ws" # WebSocket URL of the custom gateway +# platform = "line" # "telegram" (default) | "line" +# token = "${GATEWAY_TOKEN}" # shared token for WebSocket auth (optional but recommended) +# bot_username = "my_bot" # for @mention gating in groups +# allow_all_channels = true # true = allow all channels; false = only allowed_channels +# # omitted = auto-detect from list (non-empty → false, empty → true) +# allowed_channels = ["C1234"] # only checked when allow_all_channels = false +# allow_all_users = true # true = any user; false = only allowed_users +# # omitted = auto-detect from list (non-empty → false, empty → true) +# allowed_users = ["U5678"] # only checked when allow_all_users = false + [agent] command = "kiro-cli" args = ["acp", "--trust-all-tools"] diff --git a/docs/config-reference.md b/docs/config-reference.md index d69b1587..32ae7143 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -62,6 +62,23 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App --- +## `[gateway]` + +Custom Gateway adapter for platforms like Telegram and LINE. Connects to the gateway via WebSocket. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `url` | string | *required* | WebSocket URL of the gateway (e.g. `ws://openab-gateway:8080/ws`). | +| `platform` | string | `"telegram"` | Platform name for session key namespacing (e.g. `"telegram"`, `"line"`). | +| `token` | string | — | Shared token for WebSocket authentication (optional but recommended). | +| `bot_username` | string | — | Bot username for @mention gating in groups. | +| `allow_all_channels` | bool \| omit | auto-detect | `true` = all channels; `false` = only `allowed_channels`. Omitted = inferred from list (non-empty → false, empty → true). | +| `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. | + +--- + ## `[agent]` The AI agent subprocess that OpenAB spawns to handle messages via ACP. diff --git a/docs/line.md b/docs/line.md index 6c37e268..a7fe4f8d 100644 --- a/docs/line.md +++ b/docs/line.md @@ -62,6 +62,8 @@ In the LINE Developers Console → **Messaging API** tab: [gateway] url = "ws://openab-gateway:8080/ws" platform = "line" +# allowed_users = ["U1234567890abcdef"] # restrict to specific LINE user IDs +# allowed_channels = ["C1234567890abcdef"] # restrict to specific chat/group IDs [agent] command = "kiro-cli" @@ -69,6 +71,8 @@ args = ["acp", "--trust-all-tools"] working_dir = "/home/agent" ``` +> **Tip:** To find a LINE user ID, check the gateway logs — the sender ID is logged for each incoming message. By default all users and channels are allowed. Setting `allowed_users` or `allowed_channels` automatically restricts access to only those listed. + ## 6. Add the Bot as Friend In the LINE Developers Console → **Messaging API** tab → scan the QR code with your LINE app, or search for the bot by its LINE ID. diff --git a/docs/telegram.md b/docs/telegram.md index e6adf3be..d7dd9ae0 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -96,6 +96,8 @@ url = "ws://openab-gateway:8080/ws" platform = "telegram" token = "${GATEWAY_WS_TOKEN}" bot_username = "your_bot_username" +# allowed_users = ["123456789"] # restrict to specific Telegram user IDs +# allowed_channels = ["-1001234567890"] # restrict to specific chat/group IDs [agent] command = "kiro-cli" @@ -109,6 +111,8 @@ working_dir = "/home/agent" | `platform` | No | Session key namespace (default: `telegram`) | | `token` | No | Shared WS auth token (recommended) | | `bot_username` | No | Bot username for @mention gating in groups | +| `allowed_users` | No | Restrict to listed user IDs (empty = allow all) | +| `allowed_channels` | No | Restrict to listed chat IDs (empty = allow all) | ## 4. Set the Telegram Webhook diff --git a/src/config.rs b/src/config.rs index 68a6fdaf..ed2463dc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -188,6 +188,16 @@ pub struct GatewayConfig { pub token: Option, /// Bot username for @mention gating in groups (e.g. "my_bot") pub bot_username: Option, + /// Explicit flag: true = allow all channels, false = check allowed_channels list. + /// When not set, auto-detected: non-empty list → false, empty list → true. + pub allow_all_channels: Option, + /// Explicit flag: true = allow all users, false = check allowed_users list. + /// When not set, auto-detected: non-empty list → false, empty list → true. + pub allow_all_users: Option, + #[serde(default)] + pub allowed_channels: Vec, + #[serde(default)] + pub allowed_users: Vec, } fn default_gateway_platform() -> String { @@ -487,4 +497,65 @@ command = "echo" assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("failed to fetch remote config")); } + + #[test] + fn parse_gateway_config_defaults() { + let toml = r#" +[gateway] +url = "ws://gw:8080/ws" + +[agent] +command = "echo" +"#; + let cfg = parse_config(toml, "test").unwrap(); + let gw = cfg.gateway.unwrap(); + assert_eq!(gw.url, "ws://gw:8080/ws"); + assert_eq!(gw.platform, "telegram"); + assert!(gw.allowed_users.is_empty()); + assert!(gw.allowed_channels.is_empty()); + assert!(gw.allow_all_users.is_none()); + assert!(gw.allow_all_channels.is_none()); + // resolve_allow_all: empty lists → allow all + assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); + assert!(resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + } + + #[test] + fn parse_gateway_config_with_allowlists() { + let toml = r#" +[gateway] +url = "ws://gw:8080/ws" +platform = "line" +allowed_users = ["U1", "U2"] +allowed_channels = ["C1"] + +[agent] +command = "echo" +"#; + let cfg = parse_config(toml, "test").unwrap(); + let gw = cfg.gateway.unwrap(); + assert_eq!(gw.platform, "line"); + assert_eq!(gw.allowed_users, vec!["U1", "U2"]); + assert_eq!(gw.allowed_channels, vec!["C1"]); + // resolve_allow_all: non-empty lists → restricted + assert!(!resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); + assert!(!resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + } + + #[test] + fn parse_gateway_config_explicit_allow_all_overrides_list() { + let toml = r#" +[gateway] +url = "ws://gw:8080/ws" +allow_all_users = true +allowed_users = ["U1"] + +[agent] +command = "echo" +"#; + let cfg = parse_config(toml, "test").unwrap(); + let gw = cfg.gateway.unwrap(); + // explicit flag overrides non-empty list + assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); + } } diff --git a/src/gateway.rs b/src/gateway.rs index da51e4e1..5b82b270 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -258,19 +258,34 @@ impl ChatAdapter for GatewayAdapter { // --- Run the gateway adapter (connects to gateway WS, routes events to AdapterRouter) --- +/// Resolved gateway configuration passed to the adapter at startup. +pub struct GatewayParams { + pub url: String, + pub platform: String, + pub token: Option, + pub bot_username: Option, + pub allow_all_channels: bool, + pub allowed_channels: Vec, + pub allow_all_users: bool, + pub allowed_users: Vec, +} + pub async fn run_gateway_adapter( - gateway_url: String, - platform_name: String, - ws_token: Option, - bot_username: Option, + params: GatewayParams, router: Arc, mut shutdown_rx: tokio::sync::watch::Receiver, ) -> Result<()> { - // Leak the platform name for 'static lifetime — one allocation per adapter lifetime - let platform: &'static str = Box::leak(platform_name.into_boxed_str()); + let platform: &'static str = Box::leak(params.platform.into_boxed_str()); // Append auth token as query param if configured - let connect_url = match &ws_token { + 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 allow_all_users = params.allow_all_users; + let allowed_users = params.allowed_users; + + let connect_url = match ¶ms.token { Some(token) => { let sep = if gateway_url.contains('?') { "&" } else { "?" }; format!("{gateway_url}{sep}token={token}") @@ -338,6 +353,18 @@ pub async fn run_gateway_adapter( continue; // skip bot messages } + // 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"); + 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" diff --git a/src/main.rs b/src/main.rs index 8dce88a4..706fbfee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -217,8 +217,18 @@ async fn main() -> anyhow::Result<()> { let router = router.clone(); let shutdown_rx = shutdown_rx.clone(); info!(url = %gw_cfg.url, "starting gateway adapter"); + let params = gateway::GatewayParams { + url: gw_cfg.url, + platform: gw_cfg.platform, + token: gw_cfg.token, + bot_username: gw_cfg.bot_username, + allow_all_channels: config::resolve_allow_all(gw_cfg.allow_all_channels, &gw_cfg.allowed_channels), + allowed_channels: gw_cfg.allowed_channels, + allow_all_users: config::resolve_allow_all(gw_cfg.allow_all_users, &gw_cfg.allowed_users), + allowed_users: gw_cfg.allowed_users, + }; Some(tokio::spawn(async move { - if let Err(e) = gateway::run_gateway_adapter(gw_cfg.url, gw_cfg.platform, gw_cfg.token, gw_cfg.bot_username, router, shutdown_rx).await { + if let Err(e) = gateway::run_gateway_adapter(params, router, shutdown_rx).await { error!("gateway adapter error: {e}"); } }))