diff --git a/Cargo.toml b/Cargo.toml index 613597d8..aaaec8e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,3 +152,13 @@ debug = false lto = true codegen-units = 1 strip = true + +# `make install` builds this, not `release`: the published binary's fat LTO over +# one codegen unit runs single-threaded at the very end of the build, which on a +# dev box is most of the wall time. Thin LTO across parallel codegen units keeps +# the binary optimized and is several times faster to produce; `release` stays +# the shipped artifact's profile. +[profile.local-release] +inherits = "release" +lto = "thin" +codegen-units = 16 diff --git a/Makefile b/Makefile index 3693a27a..568789ef 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # ccteam Makefile — thin convenience wrappers around cargo / npm / the CLI. # # make gate # full pre-push gate: fmt + clippy + tests + SPA -# make install # THE install: build release + copy to $(BIN_DIR)/ccteam +# make install # THE install: build optimized (thin LTO) + copy to $(BIN_DIR)/ccteam # # + `ccteam daemon restart` (self-managed setsid daemon; # # migrates any legacy systemd/launchd unit) + next steps # make start # run the daemon in the FOREGROUND (dev / one-off) @@ -34,7 +34,7 @@ BIN_LINK := $(BIN_DIR)/$(BIN_NAME) # every build lands below this checkout. The first arm avoids invoking # `cargo metadata` when the caller already supplied the environment variable. CARGO_TARGET_DIR_RESOLVED = $(or $(strip $(CARGO_TARGET_DIR)),$(shell cargo metadata --no-deps --format-version 1 2>/dev/null | sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')) -RELEASE_BIN = $(abspath $(CARGO_TARGET_DIR_RESOLVED))/release/$(BIN_NAME) +INSTALL_BIN = $(abspath $(CARGO_TARGET_DIR_RESOLVED))/local-release/$(BIN_NAME) CCTEAM_HOME ?= $(HOME)/.ccteam WEB_DIR := $(CURDIR)/crates/ccteam-web/web WEB_PORT ?= 7331 @@ -78,7 +78,7 @@ help: @printf ' \033[1mmake gate\033[0m full pre-push gate (fmt+clippy+test+test-web+web-check)\n' @printf ' make clean cargo clean + rm SPA dist\n\n' @printf '\033[1mInstall\033[0m\n' - @printf ' \033[1mmake install\033[0m build release + atomic copy + `ccteam daemon restart` (self-managed daemon)\n' + @printf ' \033[1mmake install\033[0m optimized build (thin LTO) + atomic copy + `ccteam daemon restart` (self-managed daemon)\n' @printf ' make uninstall stop the daemon + remove the executable (state untouched)\n' @printf ' make reinstall uninstall + install\n\n' @printf '\033[1mRun foreground (daemon = IM gateway + web UI + MCP, one process)\033[0m\n' @@ -203,7 +203,8 @@ gate: fmt-check clippy test test-web web-check # --- Install / uninstall ----------------------------------------------------- # -# `make install` is THE product install: release build → atomic executable +# `make install` is THE product install: optimized build (Cargo profile +# `local-release` = release with thin LTO, see Cargo.toml) → atomic executable # copy → `ccteam daemon restart` (self-managed setsid daemon). The installed # executable is deliberately independent of Cargo's build tree: shared target # cleanup or a redirected CARGO_TARGET_DIR must never break the live daemon. @@ -218,11 +219,12 @@ gate: fmt-check clippy test test-web web-check # schema is owned by `ccteam_core::install_channel::InstallMarker`; unknown # fields are ignored, and `tag` is absent here on purpose (no release tag). -install-binary: release +install-binary: require-node + cargo build --profile local-release @set -eu; \ - _release_bin="$(RELEASE_BIN)"; \ + _release_bin="$(INSTALL_BIN)"; \ if [ ! -x "$$_release_bin" ]; then \ - printf '\033[31merror:\033[0m Cargo release binary not found: %s\n' "$$_release_bin" >&2; \ + printf '\033[31merror:\033[0m Cargo local-release binary not found: %s\n' "$$_release_bin" >&2; \ exit 1; \ fi; \ mkdir -p "$(BIN_DIR)"; \ diff --git a/README.md b/README.md index 9635a872..657120ce 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ ccteam is the connective tissue they lack — identity, routing, delivery guaran **1 · Remote control from Telegram / Lark / Slack** -Paste a bot token once (Settings → Access) and the chat becomes a full console — completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast. On Slack every session gets **its own thread**: a top-level message starts a session, a reply in its thread talks only to it, so parallel sessions never interleave (commands start with `!` there — `!status`, `!model` — since Slack keeps `/` for itself; Settings → Access creates the Slack app for you in one click): +Paste a bot token once (Settings → Access) and the chat becomes a full console — completion notifications, HITL `[approve] [deny]` buttons, and shipped files all land in the same thread. Dispatch at midnight, close the laptop, find the result at breakfast. In a group or channel the bot answers every message from allowed members — or, with one switch per IM, only when @-mentioned. On Slack every session gets **its own thread**: a top-level message starts a session, a reply in its thread talks only to it, so parallel sessions never interleave (commands start with `!` there — `!status`, `!model` — since Slack keeps `/` for itself; Settings → Access creates the Slack app for you in one click): ```text /cd demo # pick a project; your next message talks to it @@ -190,7 +190,7 @@ After you reboot your computer, run `ccteam start` again to bring ccteam back. **Configure in the browser** — open the printed link (also shown by `ccteam status`), create a project, and just type; the session is born on your first message. Then: -- **Settings → Access** — everything that connects to ccteam, on one page: the copy-paste MCP config for external agents (a credential scoped to one project, rendered as the real config each vendor expects, or as plain text for plugin-backed flows such as DSH, listed and revocable afterwards — the secret is shown once, never again), satellite join tokens for new machines, your own Telegram/Lark bot (a numbered two-step card per platform — save the credential, then bind who the bot answers, with sender capture starting on its own), and per-user login links +- **Settings → Access** — everything that connects to ccteam, on one page: the copy-paste MCP config for external agents (a credential scoped to one project, rendered as the real config each vendor expects, or as plain text for plugin-backed flows such as DSH, listed and revocable afterwards — the secret is shown once, never again), satellite join tokens for new machines, your own Telegram/Lark/Slack bot (a numbered two-step card per platform — save the credential, then bind who the bot answers, with sender capture starting on its own), and per-user login links - **Settings → Hosts** — each machine's vendor panel (installed / version / readiness) and one-click registration of the ccteam MCP tools into the vendor CLIs with writable config (Claude Code, Codex, Grok, OpenCode, Kimi), so even hand-started sessions can hire the team. DSH's one-click on the same page registers ccteam's plugin into your own `~/.dsh` web profile instead (a DSH session of yours can also orchestrate after pasting an Access credential); Pi gets the team tools through a ccteam-owned bridge loaded into the sessions ccteam spawns, so a `pi` you start by hand in a shell is left completely untouched - **Workflow → Marketplace** — install skills (into your user-level library `~/.ccteam/skills`; the skills tab comes first) and personas (into the project), checksum-verified; attach library skills to any message from the composer - **DSH** — open native DSH Web as a first-class console page. Each identity runs one DSH runtime and ccteam is its second client: DSH sessions hired anywhere in ccteam are created inside that same runtime, appear live in this page's sidebar under the project's workspace, and can be opened mid-task to watch or interject — the agent's next dispatch continues the same conversation. The owner sees the real `~/.dsh` space (ccteam attaches to a DSH Web already on `127.0.0.1:3080` when present); each regular user gets an isolated `$CCTEAM_HOME/runtime/dsh/web//` space with the ccteam client plugin preloaded. It works out of the box by following this machine's DSH login until the user changes DSH Settings → Models; the whole identity — menu sessions and hires alike — runs on that one config. User-installed DSH plugins are preserved. diff --git a/crates/ccteam-cli/src/commands.rs b/crates/ccteam-cli/src/commands.rs index 17465271..70ae8173 100644 --- a/crates/ccteam-cli/src/commands.rs +++ b/crates/ccteam-cli/src/commands.rs @@ -2010,7 +2010,10 @@ pub fn run_config_set_im_token(token: &str) -> Result { let creds_path = ccteam_im::credentials::default_path(); let mut creds = ccteam_im::credentials::load(Some(&creds_path)) .context("load existing IM credentials before merge")?; - creds.telegram = Some(result.creds); + let mut telegram = result.creds; + // Re-running setup must not reset the group-reply switch. + telegram.require_mention = creds.telegram.as_ref().is_some_and(|t| t.require_mention); + creds.telegram = Some(telegram); ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?; // Best-effort in-place reload of a running daemon's IM listeners (no @@ -2107,7 +2110,10 @@ pub fn run_config_set_lark_creds_with_base( } else { "Lark (intl, open.larksuite.com)" }; - creds.lark = Some(result.creds); + let mut lark = result.creds; + // Re-running setup must not reset the group-reply switch. + lark.require_mention = creds.lark.as_ref().is_some_and(|l| l.require_mention); + creds.lark = Some(lark); ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?; // Best-effort in-place reload of a running daemon's IM listeners (no @@ -2196,7 +2202,10 @@ pub fn run_config_set_slack_creds_with_base( let mut creds = ccteam_im::credentials::load(Some(&creds_path)) .context("load existing IM credentials before merge")?; let allow_count = result.creds.allowed_user_ids.len(); - creds.slack = Some(result.creds); + let mut slack = result.creds; + // Re-running setup must not reset the group-reply switch. + slack.require_mention = creds.slack.as_ref().is_some_and(|s| s.require_mention); + creds.slack = Some(slack); ccteam_im::credentials::save(&creds_path, &creds).context("persist IM credentials")?; // Best-effort live reload; skipped on the test seam (see the Lark twin). @@ -5100,6 +5109,7 @@ mod tests { telegram: Some(ccteam_im::credentials::TelegramCreds { bot_token: "tg-seed-token".into(), allowed_chat_ids: vec!["111".into()], + require_mention: false, }), ..Default::default() }; @@ -5307,6 +5317,7 @@ mod tests { app_secret: "seed_secret".into(), allowed_user_ids: vec!["ou_seed".into()], use_feishu: true, + require_mention: false, }), ..Default::default() }; @@ -5355,6 +5366,42 @@ mod tests { assert_eq!(lark.app_id, "cli_seed"); } + /// Re-running `config set slack` (a rotated token) must not reset the + /// group-reply switch the owner turned on in the web console. + #[test] + fn re_running_slack_setup_keeps_the_require_mention_switch() { + let tmp = TempDir::new().unwrap(); + let creds_path = tmp.path().join("im/credentials.json"); + let seed = ccteam_im::credentials::Credentials { + slack: Some(ccteam_im::credentials::SlackCreds { + bot_token: "xoxb-old".into(), + app_token: "xapp-old".into(), + allowed_user_ids: vec!["U0ALICE".into()], + require_mention: true, + }), + ..Default::default() + }; + ccteam_im::credentials::save(&creds_path, &seed).unwrap(); + let base = spawn_slack_http(&[ + ("/auth.test", SLACK_AUTH_OK), + ("/apps.connections.open", SLACK_SOCKET_OK), + ]); + run_config_set_slack_creds_with_base( + "xoxb-new", + "xapp-new", + vec!["U0ALICE".into()], + &base, + Some(&creds_path), + ) + .expect("re-run succeeds"); + let slack = ccteam_im::credentials::load(Some(&creds_path)) + .unwrap() + .slack + .unwrap(); + assert_eq!(slack.bot_token, "xoxb-new"); + assert!(slack.require_mention, "the switch survives a token change"); + } + #[test] fn slack_creds_empty_allowlist_warns_fail_closed() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ccteam-cli/src/update.rs b/crates/ccteam-cli/src/update.rs index 0baaf233..72a61f1c 100644 --- a/crates/ccteam-cli/src/update.rs +++ b/crates/ccteam-cli/src/update.rs @@ -697,13 +697,13 @@ fn is_executable_file(path: &Path) -> bool { std::fs::metadata(path).is_ok_and(|m| m.is_file() && is_executable(&m)) } -/// `<…>/target/{debug,release}` is a build output, not an install -/// location: `cargo clean` there would take the daemon's binary with it. +/// `<…>/target/` is a build output, not an install location: +/// `cargo clean` there would take the daemon's binary with it. fn is_cargo_build_tree(dir: &Path) -> bool { - matches!( - dir.file_name().and_then(|n| n.to_str()), - Some("debug") | Some("release") - ) && dir.parent().and_then(|p| p.file_name()) == Some(std::ffi::OsStr::new("target")) + dir.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| ccteam_core::install_channel::CARGO_BUILD_PROFILES.contains(&n)) + && dir.parent().and_then(|p| p.file_name()) == Some(std::ffi::OsStr::new("target")) } fn is_writable_dir(dir: &Path) -> bool { @@ -1273,6 +1273,7 @@ mod tests { | Some("/home/u/.local/bin/ccteam") | Some("/ro/ccteam") | Some("/src/target/debug/ccteam") + | Some("/src/target/local-release/ccteam") ) }; let writable = |d: &Path| d != Path::new("/ro"); @@ -1304,12 +1305,15 @@ mod tests { ladder(None, Some("/nope:/opt/first:/home/u/.local/bin")), Path::new("/opt/first") ); - // Rung 2 skips a cargo build tree (`cargo clean` would delete it)… - assert_eq!( - ladder(None, Some("/src/target/debug:/home/u/.local/bin")), - Path::new("/home/u/.local/bin"), - "a build tree is not an install location" - ); + // Rung 2 skips a cargo build tree (`cargo clean` would delete it), + // whichever profile built it — `make install` builds `local-release`… + for tree in ["/src/target/debug", "/src/target/local-release"] { + assert_eq!( + ladder(None, Some(&format!("{tree}:/home/u/.local/bin"))), + Path::new("/home/u/.local/bin"), + "a build tree is not an install location: {tree}" + ); + } // …and a directory it cannot write. assert_eq!( ladder(None, Some("/ro")), @@ -1344,7 +1348,10 @@ mod tests { ("1: explicit override", "CCTEAM_INSTALL_DIR"), ("2: PATH lookup", "command -v ccteam"), ("2: symlink resolution", "canonical_bin"), - ("2: build-tree exclusion", "*/target/release|*/target/debug"), + ( + "2: build-tree exclusion", + "*/target/release|*/target/local-release|*/target/debug", + ), ("2: writability", "-w \"$_dir\""), ("3: default", "$HOME/.local/bin"), ] { diff --git a/crates/ccteam-cli/src/web_chat_bridge.rs b/crates/ccteam-cli/src/web_chat_bridge.rs index 0f8234c0..eead5626 100644 --- a/crates/ccteam-cli/src/web_chat_bridge.rs +++ b/crates/ccteam-cli/src/web_chat_bridge.rs @@ -150,6 +150,7 @@ fn to_im_message(message: WebChannelMessage) -> ChannelMessage { attachments: Vec::new(), // v0.8.5 D3: a chip click carries its opaque "{token}:{idx}". selection: message.selection.map(|data| ChoiceReply { data }), + ambient: false, } } diff --git a/crates/ccteam-cli/tests/no_silent_todo_test.rs b/crates/ccteam-cli/tests/no_silent_todo_test.rs index 2e582df3..61e83b29 100644 --- a/crates/ccteam-cli/tests/no_silent_todo_test.rs +++ b/crates/ccteam-cli/tests/no_silent_todo_test.rs @@ -10,8 +10,9 @@ //! the `chat-handle` and `listbots-cache` anchors (the AgentSpec / //! BotRegistration / build_handle_map schema landed) and the //! dynamic-workflow rebuild of `ccteam-flow` deleted -//! `human-approval-adapter` with the old orchestrator, so the count is -//! now three. +//! `human-approval-adapter` with the old orchestrator, and Slack over +//! Socket Mode (#19) closed the last three (`im-providers`, +//! `slack-inbound`, `slack-socket-mode`), so none survive. //! Sister-finding sites owned by F173 / F169 / F170 cover the //! remaining markers — see `docs/dev-coupling-audit.md` for the index. //! @@ -114,14 +115,14 @@ fn no_silent_todo_in_production_src() { ); } -/// Cross-check that the surviving `TODO(V0.7-)` tag set -/// matches expectations — guards against accidental tag removal or -/// duplication. F168 originally delivered six anchors; V0.6.8 retired -/// `chat-handle` and `listbots-cache`, and the dynamic-workflow rebuild -/// of `ccteam-flow` deleted `human-approval-adapter` along with the old -/// decision-engine orchestrator, so the count is now three. +/// Every `TODO(V0.7-)` F168 deferred is closed: V0.6.8 retired +/// `chat-handle` and `listbots-cache`, the dynamic-workflow rebuild of +/// `ccteam-flow` deleted `human-approval-adapter`, and Slack over Socket +/// Mode (#19) delivered `im-providers`, `slack-inbound` and +/// `slack-socket-mode`. V0.7 is long past, so a new anchor naming it would +/// be a stale promise — defer to a version still ahead instead. #[test] -fn f168_v07_deferred_tag_count_is_three() { +fn f168_v07_deferred_anchors_are_all_closed() { let workspace_root = workspace_root(); let mut hits: Vec = Vec::new(); for root in SCAN_ROOTS { @@ -143,13 +144,10 @@ fn f168_v07_deferred_tag_count_is_three() { } }); } - assert_eq!( - hits.len(), - 3, - "exactly 3 V0.7-deferred TODO anchors survive (F168 minus \ - chat-handle and listbots-cache, closed in V0.6.8, minus \ - human-approval-adapter, deleted with the old ccteam-flow \ - orchestrator); found {}:\n{}", + assert!( + hits.is_empty(), + "no V0.7-deferred TODO anchor may survive (all six F168 anchors are \ + closed); found {}:\n{}", hits.len(), hits.join("\n") ); diff --git a/crates/ccteam-core/src/install_channel.rs b/crates/ccteam-core/src/install_channel.rs index 9539cf8a..478fda4b 100644 --- a/crates/ccteam-core/src/install_channel.rs +++ b/crates/ccteam-core/src/install_channel.rs @@ -168,10 +168,18 @@ fn env_set(env_lookup: &impl Fn(&str) -> Option, key: &str) -> bool { env_lookup(key).is_some_and(|v| !v.trim().is_empty()) } +/// The cargo profiles a source checkout builds into, `target/`: +/// the default two plus `local-release`, the thin-LTO profile `make install` +/// builds (root `Cargo.toml`). Such a directory is a build output, never an +/// install location. One list for every Rust reader; install.sh's +/// `resolve_install_dir` carries the shell copy, held in step by +/// `update::tests::install_sh_ladder_rungs_match_the_rust_copy`. +pub const CARGO_BUILD_PROFILES: &[&str] = &["debug", "release", "local-release"]; + /// Classify by executable location: /// - under `~/.local/bin` → [`InstallChannel::Standalone`] (install.sh drop); -/// - under `~/.cargo/bin` or a `target/{debug,release}` build tree → -/// [`InstallChannel::Source`]; +/// - under `~/.cargo/bin` or a `target/` build tree +/// ([`CARGO_BUILD_PROFILES`]) → [`InstallChannel::Source`]; /// - anything else → `None` (caller falls through to `Other`). fn channel_from_exe_path(exe: &Path) -> Option { let parent = exe.parent()?; @@ -181,7 +189,7 @@ fn channel_from_exe_path(exe: &Path) -> Option { if parent.ends_with(".cargo/bin") { return Some(InstallChannel::Source); } - // Cargo build tree: `<…>/target/{debug,release}[/deps]/ccteam`. + // Cargo build tree: `<…>/target/[/deps]/ccteam`. let mut dirs: Vec<&str> = exe .components() .filter_map(|c| match c { @@ -193,7 +201,7 @@ fn channel_from_exe_path(exe: &Path) -> Option { if let Some(pos) = dirs.iter().position(|c| *c == "target") { if dirs .get(pos + 1) - .is_some_and(|c| *c == "debug" || *c == "release") + .is_some_and(|c| CARGO_BUILD_PROFILES.contains(c)) { return Some(InstallChannel::Source); } @@ -307,6 +315,11 @@ mod tests { "/home/u/src/ccteam/target/release/ccteam", InstallChannel::Source, ), + // `make install` builds the thin-LTO `local-release` profile. + ( + "/home/u/src/ccteam/target/local-release/ccteam", + InstallChannel::Source, + ), // Test binaries live under target/debug/deps/… ( "/home/u/src/ccteam/target/release/deps/ccteam", diff --git a/crates/ccteam-core/src/tenants.rs b/crates/ccteam-core/src/tenants.rs index f6029f02..885bd296 100644 --- a/crates/ccteam-core/src/tenants.rs +++ b/crates/ccteam-core/src/tenants.rs @@ -40,6 +40,12 @@ pub struct TenantTelegram { /// bound (the global/owner Telegram bot has separate legacy semantics). #[serde(default)] pub allowed_chat_ids: Vec, + /// Answer only messages that @-mention the bot in a group chat (DMs are + /// always answered). `false` (default) = answer every message from an + /// allowed chat. The same switch on every IM — see + /// `ccteam_im::transport::MentionPolicy`. + #[serde(default)] + pub require_mention: bool, } /// v0.8.20 F2 — a tenant's OWN Lark/Feishu app (the per-user IM bot). @@ -54,6 +60,30 @@ pub struct TenantLark { /// `true` → Feishu (CN); `false` → Lark intl. Defaults true (CN-first). #[serde(default = "default_true")] pub use_feishu: bool, + /// Answer only messages that @-mention the bot in a group chat (DMs are + /// always answered). `false` (default) = answer every message from an + /// allowed chat. The same switch on every IM — see + /// `ccteam_im::transport::MentionPolicy`. + #[serde(default)] + pub require_mention: bool, +} + +/// A tenant's OWN Slack app (the per-user IM bot, symmetric with +/// [`TenantTelegram`] / [`TenantLark`]): the `xoxb-` bot token, the `xapp-` +/// app-level token for Socket Mode, and the Slack member ids it answers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TenantSlack { + pub bot_token: String, + pub app_token: String, + /// Slack member ids (`U…`) allowed to drive the bot. Empty = closed. + #[serde(default)] + pub allowed_user_ids: Vec, + /// Answer only messages that @-mention the bot in a group chat (DMs are + /// always answered). `false` (default) = answer every message from an + /// allowed chat. The same switch on every IM — see + /// `ccteam_im::transport::MentionPolicy`. + #[serde(default)] + pub require_mention: bool, } fn default_true() -> bool { @@ -83,6 +113,9 @@ pub struct Tenant { /// v0.8.20 F2 — this tenant's OWN Lark/Feishu app (per-user IM). #[serde(default, skip_serializing_if = "Option::is_none")] pub lark: Option, + /// This tenant's OWN Slack app (per-user IM). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slack: Option, pub created_at: DateTime, } @@ -212,6 +245,7 @@ impl TenantRegistry { linked_chat: None, telegram: None, lark: None, + slack: None, created_at: Utc::now(), }; self.tenants.push(tenant.clone()); @@ -295,6 +329,17 @@ impl TenantRegistry { } } + /// Set (or clear with `None`) a tenant's OWN Slack app. + pub fn set_slack(&mut self, id: &str, slack: Option) -> bool { + match self.tenants.iter_mut().find(|t| t.id == id) { + Some(t) => { + t.slack = slack; + true + } + None => false, + } + } + pub fn list(&self) -> &[Tenant] { &self.tenants } @@ -314,6 +359,28 @@ impl TenantRegistry { mod tests { use super::*; + /// A tenant file written before the switch existed has no `require_mention` + /// key: every IM reads it as off (answer everything), and an on value + /// survives the file round trip. + #[test] + fn require_mention_defaults_off_and_round_trips() { + let legacy = r#"{"id":"u1","handle":"a","web_token":"t","created_at":"2026-01-01T00:00:00Z", + "telegram":{"bot_token":"b"}, + "lark":{"app_id":"a","app_secret":"s"}, + "slack":{"bot_token":"b","app_token":"x"}}"#; + let mut t: Tenant = serde_json::from_str(legacy).unwrap(); + assert!(!t.telegram.as_ref().unwrap().require_mention); + assert!(!t.lark.as_ref().unwrap().require_mention); + assert!(!t.slack.as_ref().unwrap().require_mention); + t.telegram.as_mut().unwrap().require_mention = true; + t.lark.as_mut().unwrap().require_mention = true; + t.slack.as_mut().unwrap().require_mention = true; + let back: Tenant = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap(); + assert!(back.telegram.unwrap().require_mention); + assert!(back.lark.unwrap().require_mention); + assert!(back.slack.unwrap().require_mention); + } + #[test] fn add_mints_unique_id_and_token() { let mut reg = TenantRegistry::default(); @@ -408,6 +475,7 @@ mod tests { Some(TenantTelegram { bot_token: "111:AAA".into(), allowed_chat_ids: vec!["42".into()], + require_mention: false, }), ); stale.save_one(&dir, &alice.id).unwrap(); @@ -465,6 +533,7 @@ mod tests { Some(TenantTelegram { bot_token: "123:abc".into(), allowed_chat_ids: vec!["42".into()], + require_mention: false, }), )); assert_eq!( @@ -487,6 +556,7 @@ mod tests { app_secret: "s".into(), allowed_user_ids: vec![], use_feishu: true, + require_mention: false, }), )); assert!(reg.by_id(&a.id).unwrap().lark.is_some()); @@ -507,6 +577,7 @@ mod tests { Some(TenantTelegram { bot_token: "t".into(), allowed_chat_ids: vec![], + require_mention: false, }), ); reg.save(&dir).unwrap(); diff --git a/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz index 1fce76cf..1277149e 100644 Binary files a/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz and b/crates/ccteam-harness/src/execution/dsh_acp/assets/ccteam-ui.tgz differ diff --git a/crates/ccteam-harness/tests/dsh_acp_test.rs b/crates/ccteam-harness/tests/dsh_acp_test.rs index 855d0586..b770d986 100644 --- a/crates/ccteam-harness/tests/dsh_acp_test.rs +++ b/crates/ccteam-harness/tests/dsh_acp_test.rs @@ -388,8 +388,12 @@ fn patch_config(profile_dir: &Path) -> serde_yaml::Mapping { #[serial(dsh_env)] fn default_bin_is_dsh_absent_override() { let _guard = EnvGuard::capture(); + // An empty home: the fallback also finds a copy the user's own `npx dsh` + // cached under `~/.npm/_npx`, so the host's cache must not decide this. + let home = tempfile::tempdir().unwrap(); unsafe { std::env::remove_var(DSH_BIN_ENV); + std::env::set_var("HOME", home.path()); } assert_eq!(dsh_bin(), "dsh"); } diff --git a/crates/ccteam-im/src/credentials.rs b/crates/ccteam-im/src/credentials.rs index e2b9d319..1933c178 100644 --- a/crates/ccteam-im/src/credentials.rs +++ b/crates/ccteam-im/src/credentials.rs @@ -49,6 +49,12 @@ pub struct TelegramCreds { /// any chat the bot has been added to (NOT recommended for prod). #[serde(default)] pub allowed_chat_ids: Vec, + /// Answer only messages that @-mention the bot in a group chat / channel + /// (DMs are always answered). `false` (default) = answer every message + /// from an allowed sender. Symmetric on every IM; the rule itself lives in + /// [`crate::transport::MentionPolicy`]. + #[serde(default)] + pub require_mention: bool, } /// Slack app credentials. @@ -66,6 +72,12 @@ pub struct SlackCreds { /// (deny all), `"*"` = open — the same fail-closed semantics as Lark. #[serde(default)] pub allowed_user_ids: Vec, + /// Answer only messages that @-mention the bot in a group chat / channel + /// (DMs are always answered). `false` (default) = answer every message + /// from an allowed sender. Symmetric on every IM; the rule itself lives in + /// [`crate::transport::MentionPolicy`]. + #[serde(default)] + pub require_mention: bool, } /// Discord bot credentials. @@ -104,6 +116,12 @@ pub struct LarkCreds { /// `china` feature intent). #[serde(default = "default_use_feishu")] pub use_feishu: bool, + /// Answer only messages that @-mention the bot in a group chat / channel + /// (DMs are always answered). `false` (default) = answer every message + /// from an allowed sender. Symmetric on every IM; the rule itself lives in + /// [`crate::transport::MentionPolicy`]. + #[serde(default)] + pub require_mention: bool, } fn default_use_feishu() -> bool { @@ -211,6 +229,7 @@ mod tests { telegram: Some(TelegramCreds { bot_token: "TEST:abc".into(), allowed_chat_ids: vec!["12345".into()], + require_mention: false, }), ..Default::default() }; @@ -229,6 +248,7 @@ mod tests { app_secret: "secret456".into(), allowed_user_ids: vec!["ou_alice".into(), "ou_bob".into()], use_feishu: false, + require_mention: false, }), ..Default::default() }; @@ -246,6 +266,7 @@ mod tests { bot_token: "xoxb-1-abc".into(), app_token: "xapp-1-def".into(), allowed_user_ids: vec!["U0ALICE".into(), "U0BOB".into()], + require_mention: false, }), ..Default::default() }; @@ -287,6 +308,7 @@ mod tests { telegram: Some(TelegramCreds { bot_token: "x".into(), allowed_chat_ids: vec![], + require_mention: false, }), ..Default::default() }; diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 297bb1b6..8a9b8f23 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -465,8 +465,8 @@ where bind_operator_rosters(&mut *gateway.lock().await, &creds); // Which channels render buttons is the provider's fact; hand it over so // the gateway's pickers never branch on a platform name. - let button_caps = channel_button_caps(&shared_channels.read().unwrap()); - bind_channel_buttons(&mut *gateway.lock().await, &button_caps); + let caps = channel_caps(&shared_channels.read().unwrap()); + bind_channel_caps(&mut *gateway.lock().await, &caps); // V0.8.4 P2b — use the externally-supplied channel when `ccteam start` // provided one (so the mcp.sock handler shares this sender); else make // our own (standalone `ccteam-im run`). @@ -778,19 +778,34 @@ fn bind_operator_rosters(gateway: &mut Gateway, creds: &Credentials) { } } -/// Each channel's [`Channel::native_buttons`], read out of `channels` so no -/// lock is held while the gateway is updated. -fn channel_button_caps(channels: &ChannelMap) -> Vec<(String, bool)> { +/// What a channel's provider says about its presentation — read out of +/// `channels` so no lock is held while the gateway is updated. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ChannelCaps { + name: String, + /// [`Channel::native_buttons`]. + buttons: bool, + /// [`Channel::session_threads`]. + threads: bool, +} + +fn channel_caps(channels: &ChannelMap) -> Vec { channels .iter() - .map(|(name, ch)| (name.clone(), ch.native_buttons())) + .map(|(name, ch)| ChannelCaps { + name: name.clone(), + buttons: ch.native_buttons(), + threads: ch.session_threads(), + }) .collect() } -/// Report channel button capabilities to the gateway (startup + IM reload). -fn bind_channel_buttons(gateway: &mut Gateway, caps: &[(String, bool)]) { - for (name, native) in caps { - gateway.bind_channel_buttons(name, *native); +/// Report channel capabilities to the gateway (startup + IM reload), so the +/// gateway never names a platform. +fn bind_channel_caps(gateway: &mut Gateway, caps: &[ChannelCaps]) { + for cap in caps { + gateway.bind_channel_buttons(&cap.name, cap.buttons); + gateway.bind_channel_threads(&cap.name, cap.threads); } } @@ -843,7 +858,7 @@ async fn reload_im_channels( } } // A rebuilt channel's button capability goes to the gateway with it. - bind_channel_buttons(&mut *gateway.lock().await, &channel_button_caps(&rebuilt)); + bind_channel_caps(&mut *gateway.lock().await, &channel_caps(&rebuilt)); // Apply: for each rebuilt channel, abort its old listener, spawn a new one, // and (re)publish its command menu. for (name, ch) in rebuilt.iter() { @@ -897,6 +912,10 @@ async fn reload_im_channels( ); } +/// Bound on the inbound consumer's memory of threads an addressed message has +/// claimed (see `spawn_inbound_consumer`). +const ADDRESSED_THREADS_CAP: usize = 4096; + /// V0.6.1 F132 — channel-listener mpsc buffer. 64 is enough headroom /// for a slow consumer to lag behind a burst without dropping; if it /// fills the listener `await`s on `send`, which is what we want @@ -991,10 +1010,13 @@ fn build_telegram_channel( _probe_path: Option<&Path>, ) -> Option> { let tg = creds.telegram.as_ref()?; - Some(Arc::new(TelegramChannel::new( - tg.bot_token.clone(), - telegram_effective_allowlist(&tg.allowed_chat_ids, bots), - ))) + Some(Arc::new( + TelegramChannel::new( + tg.bot_token.clone(), + telegram_effective_allowlist(&tg.allowed_chat_ids, bots), + ) + .with_require_mention(tg.require_mention), + )) } /// Slack: Socket Mode inbound + Web API outbound, one thread per session. @@ -1013,7 +1035,8 @@ fn build_slack_channel( slack.bot_token.clone(), slack.app_token.clone(), slack.allowed_user_ids.clone(), - ); + ) + .with_require_mention(slack.require_mention); if let Some(path) = probe_path { ch = ch.with_probe_path(path.to_path_buf()); } @@ -1066,7 +1089,8 @@ fn build_lark_channel( lark.app_secret.clone(), allowed, lark.use_feishu, - ); + ) + .with_require_mention(lark.require_mention); if let Some(path) = probe_path { ch = ch.with_open_id_probe_path(path.to_path_buf()); } @@ -1176,6 +1200,7 @@ fn build_tenant_channels( let mut ch = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_chat_ids.clone()) .fail_closed() + .with_require_mention(tg.require_mention) .with_name(name.clone()); if let Some(path) = probe_path { ch = ch.with_rejected_sender_probe_path(path.to_path_buf()); @@ -1192,7 +1217,8 @@ fn build_tenant_channels( lk.app_secret.clone(), lk.allowed_user_ids.clone(), lk.use_feishu, - ); + ) + .with_require_mention(lk.require_mention); if let Some(path) = probe_path { ch = ch.with_open_id_probe_path(path.to_path_buf()); } @@ -1200,6 +1226,26 @@ fn build_tenant_channels( out.push((name, Arc::new(ch))); } } + #[cfg(feature = "slack")] + { + if let Some(sl) = &t.slack { + // Fail-closed like the global Slack bot: an empty member-id + // allowlist answers nobody, and the rejected ids land in the + // probe file this tenant's own setup card reads. + let name = format!("slack@{}", t.id); + let mut ch = crate::transport::providers::slack::SlackChannel::new( + sl.bot_token.clone(), + sl.app_token.clone(), + sl.allowed_user_ids.clone(), + ) + .with_require_mention(sl.require_mention) + .with_name(name.clone()); + if let Some(path) = probe_path { + ch = ch.with_probe_path(path.to_path_buf()); + } + out.push((name, Arc::new(ch))); + } + } } out } @@ -1416,6 +1462,9 @@ fn spawn_inbound_consumer( restore_complete: tokio::sync::watch::Receiver, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { + // Threads (channel, conversation, thread) an addressed message has + // claimed — see the `require_mention` gate below. + let mut addressed_threads: HashSet<(String, String, String)> = HashSet::new(); while let Some(msg) = rx.recv().await { let cid = msg.id.clone(); let route_t0 = std::time::Instant::now(); @@ -1441,6 +1490,58 @@ fn spawn_inbound_consumer( continue; }; + // The thread reaches the gateway only from a channel whose + // contract is "one thread = one session" (Slack). Everyone else + // (Telegram, Lark, web) never passes one, whatever the platform + // happened to fill in — their routing stays the single stream. + let thread = inbound_session_thread(channel.as_ref(), &msg); + + // Ambient group/channel chatter under `require_mention` is + // answered only when it continues a thread the gateway already + // holds a session in. Decided BEFORE the security layer so that + // chatter never spends the sender's rate-limit budget. + let policy = channel.mention_policy(); + if policy.require_mention { + let thread_key = thread + .as_deref() + .map(|t| (msg.channel.clone(), msg.reply_target.clone(), t.to_string())); + if msg.ambient { + // Held = the gateway has a current session in that thread, + // or an addressed message already claimed it (its session + // may still be spawning off this loop, so the gateway + // cannot know yet). + let held = match &thread_key { + Some(key) if addressed_threads.contains(key) => true, + Some((_, _, t)) => gateway.lock().await.has_current_session( + &msg.channel, + &msg.reply_target, + &msg.sender, + Some(t), + ), + None => false, + }; + if !policy.admits(msg.ambient, held) { + tracing::debug!( + channel = %msg.channel, + sender = %msg.sender, + "imd: ambient message dropped (require_mention, no held thread)" + ); + continue; + } + } else if let Some(key) = thread_key { + // Only the in-flight-spawn window needs this memory (the + // gateway's own focus covers a settled thread), so a + // bounded set that resets when full cannot lose a held + // thread; at worst it forgets a claim whose session is + // still spawning, and only if thousands of other threads + // were addressed within that same few-second window. + if addressed_threads.len() >= ADDRESSED_THREADS_CAP { + addressed_threads.clear(); + } + addressed_threads.insert(key); + } + } + // (v0.8.5 B1 / B1b) A non-text message legitimately carries empty // `content`: a selection callback (inline-button / web-chip click — // its real payload is `msg.selection`, resolved in the gateway) OR @@ -1471,12 +1572,6 @@ fn spawn_inbound_consumer( continue; }; - // The thread reaches the gateway only from a channel whose - // contract is "one thread = one session" (Slack). Everyone else - // (Telegram, Lark, web) never passes one, whatever the platform - // happened to fill in — their routing stays the single stream. - let thread = inbound_session_thread(channel.as_ref(), &msg); - let restore_incomplete = !*restore_complete.borrow(); if clean_payload.split_whitespace().next() == Some("/sessions") && restore_incomplete { // Startup restore deliberately runs outside the gateway lock @@ -2194,6 +2289,7 @@ mod tests { thread_ts: Some("1700000000.000100".into()), attachments: Vec::new(), selection: None, + ambient: false, }; assert_eq!(inbound_session_thread(&MockChannel::new(), &msg), None); assert_eq!( @@ -2214,6 +2310,7 @@ mod tests { Some(ccteam_core::tenants::TenantTelegram { bot_token: "123:abc".into(), allowed_chat_ids: vec![], + require_mention: false, }), ); let _bob = reg.add("bob"); // no IM creds → no channel @@ -2229,6 +2326,32 @@ mod tests { assert_eq!(chans[0].1.name(), format!("telegram@{}", a.id).as_str()); } + /// A regular user's OWN Slack app is a `slack@` channel like their + /// Telegram / Lark bot — and keeps Slack's contracts (a thread per session, + /// buttons) under that name. + #[cfg(feature = "slack")] + #[test] + fn a_tenants_slack_app_is_its_own_threaded_channel() { + let mut reg = ccteam_core::tenants::TenantRegistry::default(); + let a = reg.add("alice"); + reg.set_slack( + &a.id, + Some(ccteam_core::tenants::TenantSlack { + bot_token: "xoxb-a".into(), + app_token: "xapp-a".into(), + allowed_user_ids: vec!["U0ALICE".into()], + require_mention: false, + }), + ); + let chans = build_tenant_channels(®, None); + assert_eq!(chans.len(), 1); + let (name, ch) = &chans[0]; + assert_eq!(name, &format!("slack@{}", a.id)); + assert_eq!(ch.name(), name.as_str()); + assert!(ch.session_threads()); + assert!(ch.native_buttons()); + } + /// #19 — a Slack credentials block yields the Socket Mode channel under the /// `"slack"` key, and that channel carries the one-thread-per-session /// contract the inbound consumer keys on. No block → no channel. @@ -2241,6 +2364,7 @@ mod tests { bot_token: "xoxb-1".into(), app_token: "xapp-1".into(), allowed_user_ids: vec!["U1".into()], + require_mention: false, }), ..Default::default() }; @@ -2251,17 +2375,94 @@ mod tests { assert!(ch.max_message_len().is_some()); } - /// The daemon hands every live channel's button capability to the - /// gateway: a provider that renders buttons is bound, one that does not - /// (the mock) is not. + /// The `require_mention` switch reaches the live channel of EVERY IM that + /// has one — the owner's global bot and each tenant's own bot alike — so + /// the setting means the same thing wherever it is configured. + #[cfg(all(feature = "telegram", feature = "lark", feature = "slack"))] #[test] - fn channel_button_caps_come_from_the_providers() { + fn require_mention_reaches_every_ims_channel_global_and_tenant() { + use ccteam_core::tenants::{TenantLark, TenantSlack, TenantTelegram}; + for on in [false, true] { + let creds = Credentials { + telegram: Some(crate::credentials::TelegramCreds { + bot_token: "123:abc".into(), + allowed_chat_ids: vec!["1".into()], + require_mention: on, + }), + lark: Some(crate::credentials::LarkCreds { + app_id: "cli_x".into(), + app_secret: "s".into(), + allowed_user_ids: vec!["ou_1".into()], + use_feishu: true, + require_mention: on, + }), + slack: Some(crate::credentials::SlackCreds { + bot_token: "xoxb-1".into(), + app_token: "xapp-1".into(), + allowed_user_ids: vec!["U1".into()], + require_mention: on, + }), + ..Default::default() + }; + let mut seen = Vec::new(); + for (name, builder) in CHANNEL_BUILDERS { + if !["telegram", "lark", "slack"].contains(name) { + continue; + } + let ch = builder(&creds, &[], None).expect("block → channel"); + assert_eq!(ch.mention_policy().require_mention, on, "global {name}"); + seen.push(*name); + } + assert_eq!(seen.len(), 3, "all three IMs covered: {seen:?}"); + + let mut reg = ccteam_core::tenants::TenantRegistry::default(); + let t = reg.add("alice"); + reg.set_telegram( + &t.id, + Some(TenantTelegram { + bot_token: "123:abc".into(), + allowed_chat_ids: vec!["1".into()], + require_mention: on, + }), + ); + reg.set_lark( + &t.id, + Some(TenantLark { + app_id: "cli_x".into(), + app_secret: "s".into(), + allowed_user_ids: vec!["ou_1".into()], + use_feishu: true, + require_mention: on, + }), + ); + reg.set_slack( + &t.id, + Some(TenantSlack { + bot_token: "xoxb-1".into(), + app_token: "xapp-1".into(), + allowed_user_ids: vec!["U1".into()], + require_mention: on, + }), + ); + let chans = build_tenant_channels(®, None); + assert_eq!(chans.len(), 3, "one channel per tenant IM"); + for (name, ch) in &chans { + assert_eq!(ch.mention_policy().require_mention, on, "tenant {name}"); + } + } + } + + /// The daemon hands every live channel's capabilities — buttons, a thread + /// per session — to the gateway, as the provider itself reports them. + #[test] + fn channel_caps_come_from_the_providers() { let mut map: ChannelMap = HashMap::new(); let creds = Credentials { slack: Some(crate::credentials::SlackCreds { bot_token: "xoxb-1".into(), app_token: "xapp-1".into(), allowed_user_ids: vec!["U1".into()], + require_mention: false, }), ..Default::default() }; @@ -2273,12 +2474,14 @@ mod tests { "mock".into(), Arc::new(crate::transport::providers::mock::MockChannel::new()), ); - let mut caps = channel_button_caps(&map); + let mut caps = channel_caps(&map); caps.sort(); - assert_eq!( - caps, - vec![("mock".to_string(), false), ("slack".to_string(), true)] - ); + let cap = |name: &str, on: bool| ChannelCaps { + name: name.into(), + buttons: on, + threads: on, + }; + assert_eq!(caps, vec![cap("mock", false), cap("slack", true)]); } use tempfile::TempDir; @@ -2502,6 +2705,7 @@ mod tests { size: Some(908), }], selection: None, + ambient: false, }; // The consumer's gate input: a captionless attachment counts as non-text. let has_nontext = msg.selection.is_some() || !msg.attachments.is_empty(); diff --git a/crates/ccteam-im/src/gateway.rs b/crates/ccteam-im/src/gateway.rs index e3888289..2879a269 100644 --- a/crates/ccteam-im/src/gateway.rs +++ b/crates/ccteam-im/src/gateway.rs @@ -901,6 +901,18 @@ pub struct Gateway { /// daemon reported them ([`Self::bind_channel_buttons`]). Empty until the /// daemon binds its channel set — every chat then gets plain-text pickers. button_channels: BTreeSet, + /// Live channel names whose provider gives every session its own thread + /// ([`crate::transport::Channel::session_threads`]), as the daemon + /// reported them ([`Self::bind_channel_threads`]). In such a channel a + /// command typed at the channel level presets the NEXT session instead of + /// starting one, so a session's thread always opens with the person's + /// first real message ([`Self::preset_next_session`]). + thread_channels: BTreeSet, + /// The preset for a conversation's next session (`/new …` or `/use ` + /// typed at the channel level of a threaded channel), keyed by the + /// conversation; consumed by the next top-level message, whose thread the + /// session then lives in. + next_session_presets: BTreeMap, /// conversation → its current project ([`FocusScope::Conversation`]: every /// thread of a conversation works in the same project). current_project: FocusRoutes, @@ -3407,6 +3419,8 @@ enum NextStep { SessionControls, /// `/help`: the main menu, so nothing has to be typed. Menu, + /// The next session was just preset: change its model or project. + Preset, } impl NextStep { @@ -3414,7 +3428,7 @@ impl NextStep { match self { Self::Session => Some(NEXT_HINT_STATUS), Self::Sessions => Some(NEXT_HINT_SESSIONS), - Self::SessionControls | Self::Menu => None, + Self::SessionControls | Self::Menu | Self::Preset => None, } } } @@ -3441,14 +3455,46 @@ fn button_action_command(action: &str) -> Option { "model" => "/model", "interrupt" => "/interrupt", _ => { - let vendor = action.strip_prefix("new:")?; + // `new-effort::[:]` — the effort leads + // because it never contains a `:` while a model id may. + let (effort, spec) = match action.strip_prefix("new-effort:") { + Some(rest) => { + let (effort, spec) = rest.split_once(':')?; + (Some(effort), spec) + } + None => (None, action.strip_prefix("new:")?), + }; + let (vendor, model) = split_vendor_model(spec); parse_vendor(vendor).ok()?; - return Some(format!("/new {vendor}")); + let word = |w: &str| !w.is_empty() && !w.contains(char::is_whitespace); + let mut command = format!("/new {vendor}"); + if let Some(model) = model { + if !word(model) { + return None; + } + command.push_str(&format!(" model={model}")); + } + if let Some(effort) = effort { + if !word(effort) { + return None; + } + command.push_str(&format!(" effort={effort}")); + } + return Some(command); } }; Some(command.to_string()) } +/// `[:]` — the model id is everything after the first `:` (an id +/// may itself contain one, a vendor never does). +fn split_vendor_model(spec: &str) -> (&str, Option<&str>) { + match spec.split_once(':') { + Some((vendor, model)) => (vendor, Some(model)), + None => (spec, None), + } +} + /// One `act:` button. fn action_option(data: &str, label: &str, weight: OptionWeight) -> MessageOption { MessageOption { @@ -3535,6 +3581,8 @@ impl Gateway { projects, operator_chats: BTreeMap::new(), button_channels: BTreeSet::new(), + thread_channels: BTreeSet::new(), + next_session_presets: BTreeMap::new(), current_project: FocusRoutes::new(FocusScope::Conversation), current_session: FocusRoutes::new(FocusScope::Thread), sessions: BTreeMap::new(), @@ -5066,6 +5114,19 @@ impl Gateway { // An `act:` button stands for a command the user could have typed // (`button_action_command`): run it exactly as typed, in this chat. if let Some(action) = selection.and_then(|reply| reply.data.strip_prefix("act:")) { + if action == "preset" { + self.emit_harness_picker(&chat); + return Ok(Vec::new()); + } + if let Some(vendor) = action.strip_prefix("preset:") { + self.emit_model_picker(&chat, vendor); + return Ok(Vec::new()); + } + if let Some(spec) = action.strip_prefix("effort:") { + let (vendor, model) = split_vendor_model(spec); + self.emit_effort_picker(&chat, vendor, model); + return Ok(Vec::new()); + } let Some(command) = button_action_command(action) else { return Ok(vec![format!("unknown action: {action}")]); }; @@ -5119,6 +5180,9 @@ impl Gateway { "/inbox scheduled messages do not support files or skills" )); } + if let Some(reply) = self.preset_next_session(&chat, text)? { + return Ok(self.reply_with_next_step(&chat, reply, Some(NextStep::Preset))); + } if let Some(reply) = self.handle_command(&chat, text).await? { // Owner req — teach the next step (`command_next_step`): buttons // where the channel renders them, else a footer line. IM only — the @@ -5163,6 +5227,18 @@ impl Gateway { handles.dedup(); return Ok(vec![format_ambiguous_dm_reply(&handles)]); } + // A new thread's first message carries out the preset its channel + // holds (`/new …` / `/use ` typed at the channel level), so the + // session's thread opens with this message rather than a command. + let mut preset_replies = Vec::new(); + if chat.thread.is_some() && !self.current_session.contains(&chat) { + if let Some(preset) = self.next_session_presets.remove(&chat.focus_key()) { + if let Some(receipt) = self.handle_command(&chat, &preset).await? { + preset_replies = + self.reply_with_next_step(&chat, receipt, Some(NextStep::Session)); + } + } + } self.ensure_current_session(&chat).await?; // v0.8.10 — codex `/clear` = recycle + recreate at the gateway, so its // user-facing EFFECT matches Claude Code's in-thread `/clear` (a brand-new @@ -5184,7 +5260,8 @@ impl Gateway { } } let turn = wrap_inbound(channel, chat_id, user_id, message_id, text, attachments); - let mut replies = self.submit_to_current(&chat, message_id, turn).await?; + let mut replies = preset_replies; + replies.extend(self.submit_to_current(&chat, message_id, turn).await?); if chat.channel != "web" && !self.channel_supports_buttons(&chat.channel) && text.split_whitespace().next() == Some("/model") @@ -5291,9 +5368,10 @@ impl Gateway { && !Self::is_gateway_command(text) && crate::router::parse_first_mention(text).is_none(); let candidate = if candidate_shape { - !crate::latency::gateway_lock(&gateway, "im.turn.candidate") - .await - .has_current_session(channel, chat_id, user_id, thread) + let g = crate::latency::gateway_lock(&gateway, "im.turn.candidate").await; + // A preset session is started inline by `handle_message`. + !g.has_current_session(channel, chat_id, user_id, thread) + && !g.next_session_presets.contains_key(&chat.focus_key()) } else { false }; @@ -5868,6 +5946,9 @@ impl Gateway { return Ok(self.reply_with_next_step(chat, reply, Some(NextStep::Sessions))); } if let Some(sid) = nav.strip_prefix("use:") { + if let Some(reply) = self.preset_next_session(chat, &format!("/use {sid}"))? { + return Ok(self.reply_with_next_step(chat, reply, Some(NextStep::Preset))); + } let reply = self.use_session(chat, sid).await?; return Ok(self.reply_with_next_step(chat, reply, Some(NextStep::Session))); } @@ -5886,6 +5967,23 @@ impl Gateway { } } + /// Record whether the live channel `name` gives every session its own + /// thread — the provider's [`crate::transport::Channel::session_threads`], + /// reported by the daemon like [`Self::bind_channel_buttons`]. + pub fn bind_channel_threads(&mut self, name: &str, threads: bool) { + if threads { + self.thread_channels.insert(name.to_string()); + } else { + self.thread_channels.remove(name); + } + } + + /// A chat at the CHANNEL level of a channel that threads sessions — where + /// a session never lives, so commands there preset the next one. + fn at_threaded_channel_level(&self, chat: &ChatKey) -> bool { + chat.thread.is_none() && self.thread_channels.contains(&chat.channel) + } + /// Whether a chat's channel renders message `options` as tappable /// buttons ([`Self::bind_channel_buttons`]). A picker there is text + /// buttons; everywhere else (web's structured session frame, a channel @@ -6139,6 +6237,192 @@ impl Gateway { options } + /// At the channel level of a channel that threads sessions, `/new …` and + /// `/use ` do not open a session in the command's place: they PRESET + /// the conversation's next one, which the next top-level message starts + /// (or continues) in its own thread — so the thread opens with that + /// message, the session's natural title, and the preset stays visible in + /// the channel above it. Validated now, so a typo answers at once. + /// `None` = not a preset (any other chat or command). + fn preset_next_session(&mut self, chat: &ChatKey, text: &str) -> Result> { + if !self.at_threaded_channel_level(chat) { + return Ok(None); + } + let mut parts = text.split_whitespace(); + let summary = match parts.next() { + Some("/new") => { + let args: Vec<&str> = parts.collect(); + let parsed = parse_new_command_args(&args)?; + let project = self.require_current_project(chat)?; + let mut summary = format!("⚙️ 下一个会话:{}", vendor_str(parsed.vendor)); + for facet in [ + Some(parsed.role.as_str()).filter(|r| !r.is_empty()), + parsed.tuning.model.as_deref(), + parsed.tuning.effort.as_deref(), + parsed.tuning.mode.as_deref(), + (parsed.permission_mode == PermissionMode::Hitl).then_some("hitl"), + ] + .into_iter() + .flatten() + { + summary.push_str(&format!(" · {facet}")); + } + format!("{summary} · 项目 {project}\n在频道里发第一句话即开始,它就是这个会话线程的开头。") + } + Some("/use") => { + let Some(sid) = parts.next().filter(|_| parts.next().is_none()) else { + return Err(anyhow!("用法: /use ")); + }; + if !self.chat_can_access_sid(chat, sid) { + return Ok(Some(format!("unknown session for this chat: {sid}"))); + } + let title = self + .sessions + .get(sid) + .and_then(|s| self.session_title(s)) + .or_else(|| { + self.find_meta_for_sid(sid) + .ok() + .and_then(|(_, _, m)| m.title) + }); + let title = title.map(|t| format!(" ({})", truncate_cols(&t, 40))); + format!( + "⚙️ 下一条消息继续 {sid}{}\n在频道里发一句话,即把它带进这条新线程。", + title.unwrap_or_default() + ) + } + _ => return Ok(None), + }; + self.next_session_presets + .insert(chat.focus_key(), text.trim().to_string()); + Ok(Some(summary)) + } + + /// The harnesses a next session can be preset to: those this box has + /// seen report models (the advisory catalog), Claude always first. + fn emit_harness_picker(&self, chat: &ChatKey) { + let mut vendors: Vec = vec!["claude".to_string()]; + if let Some(paths) = self.project_paths.as_ref() { + for vendor in ccteam_core::model_catalog::load_model_catalog_in(&paths.root) + .0 + .keys() + { + if !vendors.contains(vendor) && parse_vendor(vendor).is_ok() { + vendors.push(vendor.clone()); + } + } + } + let options = vendors + .iter() + .map(|v| action_option(&format!("preset:{v}"), v, OptionWeight::Normal)) + .collect(); + self.emit_list_options(chat, "🧠 下一个会话用哪个 harness?".to_string(), options); + } + + /// The models `vendor` last reported (the advisory catalog), plus its + /// default; a pick presets the next session to it. + fn emit_model_picker(&self, chat: &ChatKey, vendor: &str) { + if parse_vendor(vendor).is_err() { + return; + } + // A model with an effort axis goes on to pick it (`effort:`); one + // without presets at once (`new:`) — never a menu that does nothing. + let step = |model: Option<&str>| { + let spec = match model { + Some(model) => format!("{vendor}:{model}"), + None => vendor.to_string(), + }; + let verb = if self.effort_ladder(vendor, model).is_empty() { + "new" + } else { + "effort" + }; + format!("{verb}:{spec}") + }; + let mut options = vec![action_option( + &step(None), + &format!("{vendor} · 默认模型"), + OptionWeight::Normal, + )]; + if let Some(paths) = self.project_paths.as_ref() { + let catalog = ccteam_core::model_catalog::load_model_catalog_in(&paths.root); + if let Some(found) = catalog.0.get(vendor) { + for model in &found.models { + let label = model + .display_name + .clone() + .unwrap_or_else(|| model.id.clone()); + options.push(action_option( + &step(Some(&model.id)), + &label, + OptionWeight::Normal, + )); + } + } + } + // Telegram caps callback data at 64 bytes; a pathological id drops out. + options.retain(|o| o.data.len() <= TELEGRAM_CALLBACK_MAX); + self.emit_list_options(chat, format!("🧠 {vendor} 的模型:"), options); + } + + /// Reasoning-effort levels to offer for `vendor`'s `model` (`None` = its + /// default): the model's own declared set when it has one, else the + /// vendor's ladder — the web composer's rule (`effortRowsFor`), so both + /// doors offer the same levels. Advisory like the model list: the vendor + /// owns the verdict on whatever is picked. Tokens that cannot ride a + /// button (`:` or whitespace) are left out. Empty = no effort axis. + fn effort_ladder(&self, vendor: &str, model: Option<&str>) -> Vec { + let Some(paths) = self.project_paths.as_ref() else { + return Vec::new(); + }; + let own = model.and_then(|id| { + ccteam_core::model_catalog::load_model_catalog_in(&paths.root) + .0 + .get(vendor)? + .models + .iter() + .find(|m| m.id == id) + .map(|m| m.efforts.clone()) + .filter(|efforts| !efforts.is_empty()) + }); + let mut ladder = own.unwrap_or_else(|| { + ccteam_core::model_catalog::supported_efforts_in(&paths.root, vendor) + }); + ladder.retain(|e| !e.contains(':') && !e.contains(char::is_whitespace)); + ladder + } + + /// The step after a model pick: how hard it should think. Default first + /// (wires nothing, exactly like picking no effort), then the ladder; a tap + /// presets the next session with both. + fn emit_effort_picker(&self, chat: &ChatKey, vendor: &str, model: Option<&str>) { + if parse_vendor(vendor).is_err() { + return; + } + let spec = match model { + Some(model) => format!("{vendor}:{model}"), + None => vendor.to_string(), + }; + let mut options = vec![action_option( + &format!("new:{spec}"), + "默认 effort", + OptionWeight::Normal, + )]; + for effort in self.effort_ladder(vendor, model) { + options.push(action_option( + &format!("new-effort:{effort}:{spec}"), + &effort, + OptionWeight::Normal, + )); + } + options.retain(|o| o.data.len() <= TELEGRAM_CALLBACK_MAX); + self.emit_list_options( + chat, + format!("🧠 {vendor} · {} 的 effort:", model.unwrap_or("默认模型")), + options, + ); + } + /// Deliver a command's `reply` with its next step. Where the channel /// renders buttons the step is a row of buttons under the reply, sent /// through the event sink (so no inline reply comes back); elsewhere it is @@ -6193,6 +6477,18 @@ impl Gateway { } } NextStep::Sessions => lists(Primary), + NextStep::Preset => vec![ + action_option("preset", "🧠 模型", Primary), + action_option("projects", "📁 项目", Primary), + ], + // At a threaded channel's level a session is chosen BEFORE its + // first message: pick the project and the next session's model. + NextStep::Menu if self.at_threaded_channel_level(chat) => vec![ + action_option("projects", "📁 项目", Primary), + action_option("preset", "🧠 模型", Primary), + action_option("sessions", "🧵 会话", Normal), + action_option("status", "📊 状态", Normal), + ], NextStep::Menu => vec![ action_option("projects", "📁 项目", Primary), action_option("sessions", "🧵 会话", Primary), @@ -11565,7 +11861,7 @@ impl Gateway { // this one. The thread keeps its session only if that session is in // `project`; otherwise it is freed, so its next message starts a // session there — what a new thread does anyway. - if chat.thread.is_some() { + if chat.thread.is_some() || self.at_threaded_channel_level(chat) { let kept = self.current_session.get(chat).filter(|sid| { self.sessions .get(sid) @@ -22004,6 +22300,7 @@ mod tests { telegram: Some(crate::credentials::TelegramCreds { bot_token: "123:test".into(), allowed_chat_ids, + require_mention: false, }), ..Default::default() }, @@ -27452,6 +27749,7 @@ mod tests { Some(ccteam_core::tenants::TenantTelegram { bot_token: "456:alice".into(), allowed_chat_ids: Vec::new(), + require_mention: false, }), ); tenants.save(&paths.users_dir()).unwrap(); @@ -29894,6 +30192,331 @@ mod tests { assert!(plain[0].contains("→ /status"), "{plain:?}"); } + /// A message typed at the CHANNEL level of conversation `C1` (no thread). + async fn say_at_channel(gw: &mut Gateway, text: &str) -> Vec { + gw.handle_message("slack", "C1", "U1", None, "c", text, &[], None) + .await + .unwrap() + } + + /// A button tapped on a channel-level (top-level) message of `C1`. + async fn tap_at_channel(gw: &mut Gateway, data: &str) -> Vec { + let reply = ChoiceReply { data: data.into() }; + gw.handle_message("slack", "C1", "U1", None, "m", "", &[], Some(&reply)) + .await + .unwrap() + } + + /// At the channel level of a channel that threads sessions, `/new` does + /// not start a session in the command's place: it PRESETS the next one + /// (the reply says so, visibly), and the next top-level message starts it + /// in its own thread — that message is the thread's opening line. `/use` + /// presets continuing a session the same way; `/cd` adopts nothing there. + #[tokio::test] + async fn channel_level_commands_preset_the_next_session() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + gateway.bind_channel_threads("slack", true); + let reply = say_at_channel(&mut gateway, "/new codex model=gpt-6 effort=high").await; + assert!( + reply[0].contains("下一个会话:codex · gpt-6 · high · 项目 alpha"), + "{reply:?}" + ); + assert_eq!( + fake.starts.load(Ordering::SeqCst), + 0, + "presetting spawns nothing" + ); + let bad = gateway + .handle_message( + "slack", + "C1", + "U1", + None, + "c", + "/new codex modle=x", + &[], + None, + ) + .await; + assert!(bad.is_err(), "a typo answers at once"); + + // The next top-level message starts the preset session in ITS thread. + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("5.5"), + "5.5", + "fix the login bug", + &[], + None, + ) + .await + .unwrap(); + let thread = ChatKey::new("slack", "C1", "U1").with_thread(Some("5.5")); + let sid = gateway + .current_session + .get(&thread) + .expect("bound to the thread"); + assert_eq!(gateway.sessions[&sid].vendor, AgentVendor::Codex); + assert_eq!( + fake.spawn_tunings.lock().await.clone(), + vec![(Some("gpt-6".to_string()), Some("high".to_string()))] + ); + let last = fake.submissions.lock().await.last().cloned().unwrap(); + assert!( + last.1.contains("fix the login bug"), + "the message is the first turn" + ); + + // A preset is used once: the next new thread gets the default again. + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("6.6"), + "6.6", + "another task", + &[], + None, + ) + .await + .unwrap(); + let second = ChatKey::new("slack", "C1", "U1").with_thread(Some("6.6")); + let second_sid = gateway.current_session.get(&second).unwrap(); + assert_eq!(gateway.sessions[&second_sid].vendor, AgentVendor::Claude); + + // `/use` presets continuing a session: the next message moves it. + let reply = say_at_channel(&mut gateway, "/use s1").await; + assert!(reply[0].contains("下一条消息继续 s1"), "{reply:?}"); + gateway + .handle_message( + "slack", + "C1", + "U1", + Some("7.7"), + "7.7", + "carry on", + &[], + None, + ) + .await + .unwrap(); + let third = ChatKey::new("slack", "C1", "U1").with_thread(Some("7.7")); + assert_eq!(gateway.current_session.get(&third), Some("s1".to_string())); + assert_eq!(gateway.current_session.get(&thread), None, "s1 moved here"); + + // `/cd` at the channel level switches the project, adopting nothing. + let reply = say_at_channel(&mut gateway, "/cd alpha").await; + assert!( + reply[0].contains("next message starts a session there"), + "{reply:?}" + ); + assert_eq!( + gateway + .current_session + .get(&ChatKey::new("slack", "C1", "U1")), + None + ); + + // A single-stream chat is unchanged: `/new` starts a session at once. + let starts = fake.starts.load(Ordering::SeqCst); + gateway + .handle_text("telegram", "42", "bob", "/new codex") + .await + .unwrap(); + assert_eq!(fake.starts.load(Ordering::SeqCst), starts + 1); + } + + /// The channel menu offers the next session's harness + model as taps: + /// 🧠 → a harness → its models (catalog) → a preset, without typing. + #[tokio::test] + async fn the_channel_menu_presets_a_model_by_tapping() { + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + gateway.bind_channel_threads("slack", true); + gateway.bind_channel_buttons("slack", true); + let mut events = gateway.subscribe_events(); + gateway + .handle_message("slack", "C1", "U1", None, "m", "/help", &[], None) + .await + .unwrap(); + let menu = recv_answer(&mut events).await; + let menu: Vec<&str> = menu.options.iter().map(|o| o.data.as_str()).collect(); + assert_eq!( + menu, + vec!["act:projects", "act:preset", "act:sessions", "act:status"] + ); + + tap_at_channel(&mut gateway, "act:preset").await; + let harnesses = recv_answer(&mut events).await; + assert!(harnesses + .options + .iter() + .any(|o| o.data == "act:preset:claude")); + + tap_at_channel(&mut gateway, "act:preset:codex").await; + let models = recv_answer(&mut events).await; + assert_eq!( + models.options[0].data, "act:new:codex", + "default model first" + ); + + tap_at_channel(&mut gateway, "act:new:codex:gpt-6").await; + let preset = recv_answer(&mut events).await; + assert!( + preset.content.contains("下一个会话:codex · gpt-6"), + "{}", + preset.content + ); + assert_eq!(fake.starts.load(Ordering::SeqCst), 0); + let again: Vec<&str> = preset.options.iter().map(|o| o.data.as_str()).collect(); + assert_eq!( + again, + vec!["act:preset", "act:projects"], + "change it again by tapping" + ); + } + + /// After a model, the menu asks for its reasoning effort: the model's own + /// declared levels when it has them, else the vendor's ladder (the web + /// composer's rule). A harness without an effort axis is not asked, and + /// "default" wires nothing. Every tap lands in the same preset a typed + /// `/new model= effort=` makes — model ids with a `:` included. + #[tokio::test] + async fn the_channel_menu_asks_for_effort_after_the_model() { + use ccteam_core::model_catalog::{record_vendor_models_in, CatalogModel}; + let fake = Arc::new(FakeAdapter::new(AgentVendor::Claude)); + let proj = tempfile::TempDir::new().unwrap(); + let mut gateway = Gateway::new(fake.clone(), "alpha", proj.path()); + let paths = CcteamPaths { + root: proj.path().join("home"), + projects_root: proj.path().join("projects"), + }; + let model = |id: &str, efforts: &[&str]| CatalogModel { + id: id.to_string(), + display_name: None, + efforts: efforts.iter().map(|e| e.to_string()).collect(), + }; + record_vendor_models_in( + &paths.root, + "codex", + "test", + vec![ + model("gpt-6", &["low", "high"]), + model("mini", &["minimal"]), + model("plain", &[]), + model("ollama:llama3", &["low"]), + ], + ) + .unwrap(); + record_vendor_models_in(&paths.root, "opencode", "test", vec![model("oc-1", &[])]).unwrap(); + gateway.enable_project_creation(paths); + gateway.bind_channel_threads("slack", true); + gateway.bind_channel_buttons("slack", true); + let mut events = gateway.subscribe_events(); + let data = |ev: &GatewayEvent| -> Vec { + ev.options.iter().map(|o| o.data.clone()).collect() + }; + + // The model list leads on to the effort step — except where there is + // no effort axis, which presets at once. + tap_at_channel(&mut gateway, "act:preset:codex").await; + let models = recv_answer(&mut events).await; + assert_eq!( + data(&models), + vec![ + "act:effort:codex", + "act:effort:codex:gpt-6", + "act:effort:codex:mini", + "act:effort:codex:plain", + "act:effort:codex:ollama:llama3", + ] + ); + tap_at_channel(&mut gateway, "act:preset:opencode").await; + let models = recv_answer(&mut events).await; + assert_eq!( + data(&models), + vec!["act:new:opencode", "act:new:opencode:oc-1"], + "no effort axis, no extra question" + ); + + // A model's own levels win; "default effort" comes first and wires none. + tap_at_channel(&mut gateway, "act:effort:codex:gpt-6").await; + let efforts = recv_answer(&mut events).await; + assert!(efforts.content.contains("gpt-6"), "{}", efforts.content); + assert_eq!( + data(&efforts), + vec![ + "act:new:codex:gpt-6", + "act:new-effort:low:codex:gpt-6", + "act:new-effort:high:codex:gpt-6", + ] + ); + // A model that declares none falls back to the vendor's ladder (the + // union of what its models declared); the default model does too. + for pick in ["act:effort:codex:plain", "act:effort:codex"] { + tap_at_channel(&mut gateway, pick).await; + let efforts = recv_answer(&mut events).await; + let levels: Vec = data(&efforts) + .iter() + .skip(1) + .map(|d| d.split(':').nth(2).unwrap().to_string()) + .collect(); + assert_eq!(levels, vec!["low", "high", "minimal"], "{pick}"); + } + + // The tap presets exactly what typing it would — then the first + // message spawns with both. + tap_at_channel(&mut gateway, "act:new-effort:high:codex:gpt-6").await; + let preset = recv_answer(&mut events).await; + assert!( + preset.content.contains("下一个会话:codex · gpt-6 · high"), + "{}", + preset.content + ); + gateway + .handle_message("slack", "C1", "U1", Some("5.5"), "5.5", "hi", &[], None) + .await + .unwrap(); + assert_eq!( + fake.spawn_tunings.lock().await.clone(), + vec![(Some("gpt-6".to_string()), Some("high".to_string()))] + ); + + // The spawn announced itself; drop that so the next Answer is ours. + while events.try_recv().is_ok() {} + + // A model id containing `:` survives the round trip; "default effort" + // presets the model alone. + tap_at_channel(&mut gateway, "act:new-effort:low:codex:ollama:llama3").await; + let preset = recv_answer(&mut events).await; + assert!( + preset + .content + .contains("下一个会话:codex · ollama:llama3 · low"), + "{}", + preset.content + ); + tap_at_channel(&mut gateway, "act:new:codex:gpt-6").await; + let preset = recv_answer(&mut events).await; + assert!( + preset.content.contains("下一个会话:codex · gpt-6 · 项目"), + "no effort facet: {}", + preset.content + ); + + // A malformed effort button is refused, not guessed at. + assert_eq!(button_action_command("new-effort:high"), None); + assert_eq!(button_action_command("new-effort::codex"), None); + assert_eq!(button_action_command("new-effort:hi gh:codex"), None); + } + /// A thread whose first plain message spawns its session opens with a /// header carrying that session's controls — in that thread. #[tokio::test] diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index c1d9e165..5b706256 100644 --- a/crates/ccteam-im/src/mcp/dispatch.rs +++ b/crates/ccteam-im/src/mcp/dispatch.rs @@ -617,6 +617,7 @@ pub(crate) fn user_delivery_target( let channel = match platform { "telegram" if tenant.telegram.is_some() => format!("telegram@{user_id}"), "lark" if tenant.lark.is_some() => format!("lark@{user_id}"), + "slack" if tenant.slack.is_some() => format!("slack@{user_id}"), other => other.to_string(), }; return Ok((channel, chat_id.to_string())); @@ -636,6 +637,14 @@ pub(crate) fn user_delivery_target( { return Ok((format!("lark@{user_id}"), open_id.clone())); } + // Slack posts to a member id as the app's DM with that member. + if let Some(member_id) = tenant + .slack + .as_ref() + .and_then(|slack| slack.allowed_user_ids.first()) + { + return Ok((format!("slack@{user_id}"), member_id.clone())); + } Err( "chat_send_file: no linked IM destination is configured for this user; link an IM chat or configure the tenant bot recipient first" @@ -6108,6 +6117,7 @@ mod chat_send_file_tests { Some(ccteam_core::tenants::TenantTelegram { bot_token: "123:test".into(), allowed_chat_ids: Vec::new(), + require_mention: false, }), ); tenants.save(&paths.users_dir()).unwrap(); @@ -8104,6 +8114,7 @@ mod session_tool_tests { telegram: Some(crate::credentials::TelegramCreds { bot_token: "123:test".into(), allowed_chat_ids: vec!["admin-chat".into()], + require_mention: false, }), ..Default::default() }, diff --git a/crates/ccteam-im/src/onboarding.rs b/crates/ccteam-im/src/onboarding.rs index bee3c26b..bd210084 100644 --- a/crates/ccteam-im/src/onboarding.rs +++ b/crates/ccteam-im/src/onboarding.rs @@ -129,6 +129,7 @@ pub async fn telegram_setup_with_base( creds: TelegramCreds { bot_token: token.into(), allowed_chat_ids: vec![owner_chat_id.to_string()], + require_mention: false, }, bot_username, }) @@ -349,6 +350,7 @@ pub async fn lark_setup_with_base( app_secret: app_secret.into(), allowed_user_ids, use_feishu, + require_mention: false, }, }) } @@ -607,6 +609,7 @@ pub async fn slack_setup_with_base( bot_token: bot_token.into(), app_token: app_token.into(), allowed_user_ids, + require_mention: false, }, team: auth.team.unwrap_or_default(), bot_user: auth.user.unwrap_or_default(), diff --git a/crates/ccteam-im/src/transport/mod.rs b/crates/ccteam-im/src/transport/mod.rs index fe327ca6..dc11903b 100644 --- a/crates/ccteam-im/src/transport/mod.rs +++ b/crates/ccteam-im/src/transport/mod.rs @@ -109,6 +109,46 @@ pub struct ChannelMessage { /// text. `None` for ordinary messages. #[serde(default)] pub selection: Option, + /// A group-chat / channel message that does NOT address the bot (no + /// @-mention of it, not a reply to it) — "ambient" chatter in a space the + /// bot merely sits in. A DM, a slash command and an option click are never + /// ambient. Every provider reports this one fact; whether an ambient + /// message is answered is [`MentionPolicy`]'s call, in one place. + #[serde(default)] + pub ambient: bool, +} + +/// The per-IM "must I be @-mentioned in a group?" switch +/// (`require_mention` in the credentials), and the ONE rule that applies it — +/// so Telegram, Lark and Slack cannot drift apart. Providers report +/// [`ChannelMessage::ambient`]; this decides what an ambient message does. +/// +/// Off (the default): every message from an allowed sender is answered. On: +/// an ambient message is answered only when it continues a conversation the +/// gateway already holds — a platform thread with a current session (Slack's +/// thread-per-session). A flat chat has no such thread, so there it takes an +/// @-mention every time. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct MentionPolicy { + /// `require_mention` from the IM's credentials block. + pub require_mention: bool, +} + +impl MentionPolicy { + /// Provider-side early drop, BEFORE any attachment download or other + /// per-message work: an ambient message outside a thread can never + /// continue a held conversation, so under this policy it is dropped for + /// certain. (An ambient message inside a thread may — that needs the + /// gateway; see [`Self::admits`].) + pub fn drops_early(self, ambient: bool, in_thread: bool) -> bool { + self.require_mention && ambient && !in_thread + } + + /// The final verdict, with the gateway's answer to "does this thread + /// already have a current session here" (`thread_held`). + pub fn admits(self, ambient: bool, thread_held: bool) -> bool { + !(self.require_mention && ambient) || thread_held + } } /// v0.8.20 F2 — a per-tenant IM bot's channel name is `"@"` @@ -622,6 +662,13 @@ pub trait Channel: Send + Sync { false } + /// This channel's [`MentionPolicy`] (from its credentials' + /// `require_mention`); the daemon's inbound consumer applies it to every + /// [`ChannelMessage::ambient`] message. **Default**: answer everything. + fn mention_policy(&self) -> MentionPolicy { + MentionPolicy::default() + } + /// Whether this channel renders [`SendMessage::options`] as tappable /// buttons whose click comes back as a [`ChannelMessage::selection`] /// (Telegram inline keyboard, Slack Block Kit). The daemon reports it to @@ -763,6 +810,36 @@ mod tests { use super::*; use crate::transport::providers::mock::MockChannel; + /// The one rule behind every IM's `require_mention` switch. + #[test] + fn mention_policy_truth_table() { + let off = MentionPolicy::default(); + let on = MentionPolicy { + require_mention: true, + }; + // Off (the default) answers everything, ambient or not. + for (ambient, in_thread, held) in [ + (false, false, false), + (true, false, false), + (true, true, false), + (true, true, true), + ] { + assert!(!off.drops_early(ambient, in_thread)); + assert!(off.admits(ambient, held)); + } + // On: an addressed message (DM, @-mention, command, click) always passes… + assert!(!on.drops_early(false, false)); + assert!(!on.drops_early(false, true)); + assert!(on.admits(false, false)); + // …ambient chatter outside a thread is dropped for certain… + assert!(on.drops_early(true, false)); + // …an ambient reply inside a thread is the gateway's call: it passes + // only when that thread already holds a session. + assert!(!on.drops_early(true, true)); + assert!(!on.admits(true, false)); + assert!(on.admits(true, true)); + } + /// v0.8.20 F2 — the platform prefix is what platform-keyed logic (ACL, /// menus) uses; the `@`-suffixed full name is the per-bot routing key. #[test] diff --git a/crates/ccteam-im/src/transport/providers/discord.rs b/crates/ccteam-im/src/transport/providers/discord.rs index 5274931f..c0167bfb 100644 --- a/crates/ccteam-im/src/transport/providers/discord.rs +++ b/crates/ccteam-im/src/transport/providers/discord.rs @@ -179,6 +179,7 @@ impl Channel for DiscordChannel { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }; { let mut map = self.last_id.lock().await; diff --git a/crates/ccteam-im/src/transport/providers/lark.rs b/crates/ccteam-im/src/transport/providers/lark.rs index 3377947a..575165f8 100644 --- a/crates/ccteam-im/src/transport/providers/lark.rs +++ b/crates/ccteam-im/src/transport/providers/lark.rs @@ -43,7 +43,7 @@ use tokio_tungstenite::tungstenite::Message as WsMsg; use crate::transport::{ inbound_staging_dir, sanitize_attachment_name, AttachmentKind, Channel, ChannelAttachment, - ChannelMessage, ChoiceReply, MessageOption, OutboundFile, OutboundFileKind, + ChannelMessage, ChoiceReply, MentionPolicy, MessageOption, OutboundFile, OutboundFileKind, RejectedSenderNotifier, RejectedSenderProbe, SendMessage, }; @@ -220,6 +220,9 @@ struct DecodedMessage { /// plain text/`post`. Decoding stays pure (no `&self`/network); the /// actual download happens in [`LarkChannel::stage_lark_attachment`]. pending: Option, + /// A group-chat message that does not @-mention the bot + /// ([`ChannelMessage::ambient`]); a p2p message never is. + ambient: bool, } /// A Lark message resource (image or file) the WS loop should download. @@ -241,8 +244,8 @@ struct LarkPending { /// applying every content/visibility rule the live WS loop applies *except* /// the allowlists and dedup (those need `&self` / shared state and stay at /// the call sites). Returns `None` to skip — bot/app sender, missing -/// open_id, unsupported `message_type`, empty body, or a group message the -/// bot wasn't @-mentioned in. +/// open_id, unsupported `message_type`, or empty body. (A group message the +/// bot wasn't @-mentioned in is decoded too, marked `ambient`.) fn decode_message_receive(recv: &MsgReceivePayload) -> Option { // Drop the bot's own (and other apps') messages. if recv.sender.sender_type == "app" || recv.sender.sender_type == "bot" { @@ -289,12 +292,10 @@ fn decode_message_receive(recv: &MsgReceivePayload) -> Option { return None; } - // Group chat: only respond when explicitly @-mentioned. Applies to - // attachments too (a group image with no @-mention is ignored, matching - // the text rule). - if msg.chat_type == "group" && !should_respond_in_group(&msg.mentions) { - return None; - } + // A group message that does not @-mention the bot is "ambient" — whether + // it is answered is the channel's `MentionPolicy` (`require_mention`), + // for text and attachments alike. + let ambient = msg.chat_type == "group" && !should_respond_in_group(&msg.mentions); let timestamp = msg .create_time @@ -311,6 +312,7 @@ fn decode_message_receive(recv: &MsgReceivePayload) -> Option { text, timestamp, pending, + ambient, }) } @@ -438,6 +440,7 @@ impl DecodedMessage { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: self.ambient, } } } @@ -478,6 +481,8 @@ pub struct LarkChannel { ws_seen_ids: Arc>>, /// Shared setup probe + one-shot binding notice for rejected senders. rejected_senders: RejectedSenderNotifier, + /// Whether a group message must @-mention the bot to be answered. + mention_policy: MentionPolicy, name: String, } @@ -502,10 +507,26 @@ impl LarkChannel { tenant_token: Arc::new(RwLock::new(None)), ws_seen_ids: Arc::new(RwLock::new(HashMap::new())), rejected_senders: RejectedSenderNotifier::default(), + mention_policy: MentionPolicy::default(), name: "lark".to_string(), } } + /// Answer a group message only when it @-mentions the bot — the + /// credentials' `require_mention`. p2p chats and card clicks are + /// unaffected. + pub fn with_require_mention(mut self, require_mention: bool) -> Self { + self.mention_policy = MentionPolicy { require_mention }; + self + } + + /// Whether `decoded` is ambient group chatter this bot must not answer. + /// Lark has no threads, so an ambient message is dropped for certain — + /// before its attachment is downloaded. + fn drops_ambient(&self, decoded: &DecodedMessage) -> bool { + self.mention_policy.drops_early(decoded.ambient, false) + } + /// v0.8.20 F2 — override the channel-map key (`"lark@"`) for a /// per-tenant bot (see [`super::telegram::TelegramChannel::with_name`]). pub fn with_name(mut self, name: String) -> Self { @@ -793,6 +814,7 @@ impl LarkChannel { thread_ts: None, attachments: Vec::new(), selection: Some(ChoiceReply { data: action.data }), + ambient: false, }; if tx.send(cm).await.is_err() { break; } continue; @@ -816,6 +838,10 @@ impl LarkChannel { ).await; continue; } + if self.drops_ambient(&decoded) { + tracing::debug!(message_id = %decoded.message_id, "Lark WS: ambient group message dropped (require_mention)"); + continue; + } // Dedup. Scope the write guard so it is dropped BEFORE // the `tx.send(..).await` below (no guard held across an @@ -1206,7 +1232,8 @@ impl LarkChannel { /// (which needs the shared `ws_seen_ids` mutable state). Returns `None` /// for anything the loop would `continue` past: wrong event type, bot /// sender, missing/disallowed `open_id`, unsupported `message_type`, - /// empty body, or an un-@-mentioned group message. + /// empty body, or — under `require_mention` — an un-@-mentioned group + /// message. /// /// This is the tested seam, and it shares [`decode_message_receive`] + /// [`DecodedMessage::into_channel_message`] with [`Self::listen_ws`], so @@ -1230,6 +1257,9 @@ impl LarkChannel { ); return None; } + if self.drops_ambient(&decoded) { + return None; + } let mut cm = decoded.into_channel_message(); cm.channel = self.name.clone(); Some(cm) @@ -1265,6 +1295,10 @@ impl Channel for LarkChannel { &self.name } + fn mention_policy(&self) -> MentionPolicy { + self.mention_policy + } + async fn send(&self, message: &SendMessage) -> anyhow::Result> { // V0.8.4 P2b — files go via `im/v1/{images,files}` upload + an // image/file message; the caption rides the first attachment. @@ -1633,7 +1667,10 @@ fn strip_at_placeholders(text: &str) -> String { result } -/// In group chats, only respond when the bot is explicitly @-mentioned. +/// Whether a group message addresses the bot. The event lists who it +/// @-mentions; unless the app holds the receive-all-group-messages +/// permission Lark only delivers a group message that @-mentions the app, so a +/// non-empty list is taken to mean it. fn should_respond_in_group(mentions: &[serde_json::Value]) -> bool { !mentions.is_empty() } diff --git a/crates/ccteam-im/src/transport/providers/lark_tests.rs b/crates/ccteam-im/src/transport/providers/lark_tests.rs index 0f42838a..8f742b61 100644 --- a/crates/ccteam-im/src/transport/providers/lark_tests.rs +++ b/crates/ccteam-im/src/transport/providers/lark_tests.rs @@ -402,7 +402,7 @@ fn lark_decode_skips_app_and_bot_senders() { } #[test] -fn lark_decode_group_requires_at_mention() { +fn lark_group_message_without_mention_is_ambient_and_answered_by_default() { let ch = LarkChannel::new("id".into(), "secret".into(), vec!["*".into()], true); let base = |mentions: serde_json::Value| { serde_json::json!({ @@ -421,15 +421,53 @@ fn lark_decode_group_requires_at_mention() { } }) }; - // No @-mention in a group → dropped. - assert!(ch + // Default policy: a group message with no @-mention is delivered, marked + // ambient (the same switch every IM has — Lark no longer hard-codes it). + let chatter = ch .decode_event_value(&base(serde_json::json!([]))) - .is_none()); - // @-mentioned → delivered. + .expect("answered by default"); + assert!(chatter.ambient); + // @-mentioned → delivered, and not ambient. let msg = ch .decode_event_value(&base(serde_json::json!([{"key": "@_user_1"}]))) .expect("mentioned group message delivered"); assert_eq!(msg.reply_target, "oc_group"); + assert!(!msg.ambient); +} + +#[test] +fn lark_require_mention_drops_ambient_group_messages() { + let ch = LarkChannel::new("id".into(), "secret".into(), vec!["*".into()], true) + .with_require_mention(true); + assert!(ch.mention_policy().require_mention); + let group = |mentions: serde_json::Value| { + serde_json::json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "sender": { "sender_id": { "open_id": "ou_user" } }, + "message": { + "message_id": "om_grp", + "message_type": "text", + "content": "{\"text\":\"hi team\"}", + "chat_id": "oc_group", + "chat_type": "group", + "mentions": mentions, + "create_time": "1000" + } + } + }) + }; + assert!(ch + .decode_event_value(&group(serde_json::json!([]))) + .is_none()); + assert!(ch + .decode_event_value(&group(serde_json::json!([{"key": "@_user_1"}]))) + .is_some()); + // A p2p chat has no group to be ambient in. + let mut p2p = group(serde_json::json!([])); + p2p["event"]["message"]["chat_type"] = serde_json::json!("p2p"); + let msg = ch.decode_event_value(&p2p).expect("p2p is always answered"); + assert!(!msg.ambient); } #[test] @@ -876,8 +914,9 @@ fn lark_decode_file_yields_pending() { } #[test] -fn lark_decode_group_image_requires_at_mention() { - // The group @-mention gate applies to attachments too. +fn lark_decode_group_image_is_ambient_without_at_mention() { + // Attachments follow the same ambient rule as text; the channel's policy + // (not the decoder) decides, and drops it before the download. let ch = LarkChannel::new("id".into(), "secret".into(), vec!["*".into()], true); let event = |mentions: serde_json::Value| { serde_json::to_vec(&serde_json::json!({ @@ -897,10 +936,22 @@ fn lark_decode_group_image_requires_at_mention() { })) .unwrap() }; - assert!(ch.decode_event(&event(serde_json::json!([]))).is_none()); - assert!(ch + let ambient = ch + .decode_event(&event(serde_json::json!([]))) + .expect("decoded"); + assert!(ambient.ambient && ambient.pending.is_some()); + assert!(!ch.drops_ambient(&ambient)); + let strict = LarkChannel::new("id".into(), "secret".into(), vec!["*".into()], true) + .with_require_mention(true); + assert!( + strict.drops_ambient(&ambient), + "dropped before the download" + ); + let mentioned = ch .decode_event(&event(serde_json::json!([{"key": "@_user_1"}]))) - .is_some()); + .expect("decoded"); + assert!(!mentioned.ambient); + assert!(!strict.drops_ambient(&mentioned)); } // ── attachments: upload-response + magic-byte helpers (pure) ──────────── diff --git a/crates/ccteam-im/src/transport/providers/mock.rs b/crates/ccteam-im/src/transport/providers/mock.rs index eef052cc..9d335a17 100644 --- a/crates/ccteam-im/src/transport/providers/mock.rs +++ b/crates/ccteam-im/src/transport/providers/mock.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use async_trait::async_trait; use tokio::sync::Mutex; -use crate::transport::{Channel, ChannelMessage, CommandSpec, SendMessage}; +use crate::transport::{Channel, ChannelMessage, CommandSpec, MentionPolicy, SendMessage}; /// One recorded reaction call (v0.8.19): `(op, chat_id, message_id, handle)` /// where `op` is `"add"`/`"remove"` and `handle` is what was passed to remove @@ -57,6 +57,9 @@ pub struct MockChannel { /// single-stream shape); a test flips it to drive the threaded routing a /// Slack channel gets. session_threads: bool, + /// Report [`Channel::mention_policy`] with `require_mention` — `false` by + /// default (answer everything). + require_mention: bool, } impl MockChannel { @@ -73,9 +76,18 @@ impl MockChannel { reaction_handle: None, reactions: Arc::default(), session_threads: false, + require_mention: false, } } + /// Make this channel's [`Channel::mention_policy`] require an @-mention + /// in a group (the credentials' `require_mention`), so a test can drive + /// the daemon's ambient-message gate. Builder-style; default `false`. + pub fn with_require_mention(mut self) -> Self { + self.require_mention = true; + self + } + /// Make this channel report [`Channel::session_threads`] `== true` (one /// thread = one session, the Slack contract), so a test can drive the /// daemon's threaded inbound path. Builder-style; default stays `false`. @@ -187,6 +199,12 @@ impl Channel for MockChannel { self.session_threads } + fn mention_policy(&self) -> MentionPolicy { + MentionPolicy { + require_mention: self.require_mention, + } + } + async fn edit_message( &self, _recipient: &str, @@ -257,6 +275,7 @@ mod tests { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; let (tx, mut rx) = tokio::sync::mpsc::channel(8); diff --git a/crates/ccteam-im/src/transport/providers/slack.rs b/crates/ccteam-im/src/transport/providers/slack.rs index 8d79040e..2392799c 100644 --- a/crates/ccteam-im/src/transport/providers/slack.rs +++ b/crates/ccteam-im/src/transport/providers/slack.rs @@ -54,8 +54,8 @@ use tokio_tungstenite::tungstenite::Message as WsMsg; use crate::onboarding::{client_for_api_base, SLACK_API_BASE}; use crate::transport::{ inbound_staging_dir, sanitize_attachment_name, AttachmentKind, Channel, ChannelAttachment, - ChannelMessage, ChoiceReply, CommandSpec, MessageOption, OptionWeight, OutboundFile, - RejectedSenderNotifier, RejectedSenderProbe, SendMessage, + ChannelMessage, ChoiceReply, CommandSpec, MentionPolicy, MessageOption, OptionWeight, + OutboundFile, RejectedSenderNotifier, RejectedSenderProbe, SendMessage, }; /// Per-message ceiling in **UTF-16 code units**. Slack caps the cumulative @@ -229,10 +229,20 @@ struct DecodedMessage { channel: String, channel_type: String, ts: String, - /// Resolved thread: the event's `thread_ts`, else its own `ts`. - thread_ts: String, + /// Resolved thread: the event's `thread_ts`; for a top-level message its + /// own `ts` (it opens a thread) — except a top-level COMMAND, which acts + /// at the channel level (`None`) so a session's thread opens with a real + /// message rather than the command that preset it. + thread_ts: Option, text: String, mentions_bot: bool, + /// The event is a reply inside an existing thread (it carried a parent + /// `thread_ts`) — as opposed to a top-level message, which only opens + /// one. Only a reply can continue a conversation the gateway holds. + in_thread: bool, + /// A channel / private-channel / group-DM message that does not @-mention + /// the bot ([`ChannelMessage::ambient`]). + ambient: bool, files: Vec, } @@ -245,9 +255,10 @@ impl DecodedMessage { reply_target: self.channel, content: self.text, channel: channel_name.to_string(), - thread_ts: Some(self.thread_ts), + thread_ts: self.thread_ts, attachments: Vec::new(), selection: None, + ambient: self.ambient, } } } @@ -285,7 +296,7 @@ fn decode_message_event(event: &Value, bot: &BotIdentity) -> Option Option Some(parent.to_string()), + None if text.starts_with('/') => None, + None => Some(ts.to_string()), + }; let files: Vec = event .get("files") @@ -308,9 +324,11 @@ fn decode_message_event(event: &Value, bot: &BotIdentity) -> Option String { .replace("&", "&") } -/// Escape user text for Slack's `text` field. -fn escape_entities(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") -} - /// A `slash_commands` envelope payload. #[derive(Debug, Clone, PartialEq)] struct SlashCommand { @@ -498,25 +509,16 @@ fn rewrite_line_sigils(line: &str, names: &[String], out: &mut String) { } } -/// Text of the top-level anchor message a slash command threads under — -/// it echoes the command so the thread reads as "who asked for what". -fn anchor_text(command: &str, text: &str, user_id: &str) -> String { - let text = unescape_entities(text).trim().to_string(); - let invocation = if text.is_empty() { - command.to_string() - } else { - format!("{command} {text}") - }; - format!("`{}` · <@{user_id}>", escape_entities(&invocation)) -} - /// A `block_actions` click on one of our option buttons. #[derive(Debug, Clone, PartialEq)] struct ButtonClick { user_id: String, channel_id: String, message_ts: String, - thread_ts: String, + /// The clicked message's thread; `None` for a button on a top-level + /// message (a channel-level menu or list), which acts at the channel + /// level like the command that posted it. + thread_ts: Option, value: String, trigger_id: String, } @@ -535,12 +537,12 @@ fn decode_block_actions(payload: &Value) -> Option { str_at(payload, "/container/message_ts").or_else(|| str_at(payload, "/message/ts"))?; let thread_ts = str_at(payload, "/container/thread_ts") .or_else(|| str_at(payload, "/message/thread_ts")) - .unwrap_or(message_ts); + .map(str::to_string); Some(ButtonClick { user_id: user_id.to_string(), channel_id: channel_id.to_string(), message_ts: message_ts.to_string(), - thread_ts: thread_ts.to_string(), + thread_ts, value: value.to_string(), trigger_id: str_at(payload, "/trigger_id").unwrap_or("").to_string(), }) @@ -761,6 +763,8 @@ pub struct SlackChannel { bot_token: String, app_token: String, allowed_users: Vec, + /// Whether a channel message must @-mention the bot to be answered. + mention_policy: MentionPolicy, api_base: String, http: reqwest::Client, /// `auth.test` result, fetched once per listener (re-fetched only if a @@ -777,6 +781,10 @@ pub struct SlackChannel { /// ccteam's own command names (no `/`), as the daemon registers them — /// what [`rewrite_command_sigils`] turns into `!name` on the way out. command_names: RwLock>, + /// The latest slash command's `response_url` per channel: if the bot is + /// not a member there, its channel-level reply cannot be posted, and this + /// is the one way left to tell the person (an ephemeral notice). + slash_response_urls: Mutex>, name: String, } @@ -789,6 +797,7 @@ impl SlackChannel { bot_token, app_token, allowed_users: allowed_user_ids, + mention_policy: MentionPolicy::default(), api_base: SLACK_API_BASE.to_string(), http: Self::http_for(SLACK_API_BASE), bot: RwLock::new(None), @@ -797,6 +806,7 @@ impl SlackChannel { probe_path: None, rejected_senders: RejectedSenderNotifier::default(), command_names: RwLock::new(Vec::new()), + slash_response_urls: Mutex::new(std::collections::HashMap::new()), name: "slack".to_string(), } } @@ -828,6 +838,14 @@ impl SlackChannel { self } + /// Answer a channel message only when it @-mentions the bot (or continues + /// a thread the gateway already holds a session in) — the credentials' + /// `require_mention`. DMs, slash commands and button clicks are unaffected. + pub fn with_require_mention(mut self, require_mention: bool) -> Self { + self.mention_policy = MentionPolicy { require_mention }; + self + } + /// Record rejected senders to this JSONL path (the web binding flow /// reads it). Unset in tests / standalone use. pub fn with_probe_path(mut self, path: PathBuf) -> Self { @@ -958,16 +976,6 @@ impl SlackChannel { Ok(str_at(&v, "/ts").map(str::to_string)) } - /// Post plain `text` (Slack's own `mrkdwn`, where `<@U…>` renders as a - /// mention) — used for the slash-command anchor. - async fn post_plain(&self, channel: &str, text: &str) -> anyhow::Result> { - let body = post_body(channel, None, text, &[], false); - let v = self - .call_bot("chat.postMessage", ApiBody::Json(&body)) - .await?; - Ok(str_at(&v, "/ts").map(str::to_string)) - } - /// Files go through Slack's external-upload flow /// (`files.getUploadURLExternal` → POST bytes → `files.completeUploadExternal`). /// @@ -1217,6 +1225,16 @@ impl SlackChannel { .await; return None; } + // Ambient chatter that cannot continue a held thread is dropped here, + // before its files are downloaded; a reply inside a thread is left to + // the daemon, which knows whether the gateway holds that thread. + if self + .mention_policy + .drops_early(decoded.ambient, decoded.in_thread) + { + tracing::debug!(ts = %decoded.ts, "slack: ambient channel message dropped (require_mention)"); + return None; + } let files = decoded.files.clone(); let mut message = decoded.into_channel_message(&self.name); for file in &files { @@ -1274,36 +1292,29 @@ impl SlackChannel { .await; return None; } - let anchor = anchor_text(&command.command, &command.text, &command.user_id); - let anchor_ts = match self.post_plain(&command.channel_id, &anchor).await { - Ok(Some(ts)) => ts, - Ok(None) => { - let err = anyhow::anyhow!("chat.postMessage returned no ts"); - self.respond_ephemeral(&command.response_url, &anchor_failure_notice(&err)) - .await; - return None; - } - Err(err) => { - tracing::warn!( - channel = %command.channel_id, - error = %err, - "slack: slash-command anchor post failed" - ); - self.respond_ephemeral(&command.response_url, &anchor_failure_notice(&err)) - .await; - return None; - } + // The command answers at the channel level (no thread): its reply — a + // preset, a list, a confirmation — is a channel message everyone sees, + // and a session's thread is left to open with a real message. + self.slash_response_urls + .lock() + .await + .insert(command.channel_id.clone(), command.response_url.clone()); + let id = if command.trigger_id.is_empty() { + format!("slash-{}", now_secs()) + } else { + command.trigger_id.clone() }; Some(ChannelMessage { - timestamp: ts_secs(&anchor_ts), - id: anchor_ts.clone(), + timestamp: now_secs(), + id, sender: command.user_id, reply_target: command.channel_id, content: slash_content(&command.text), channel: self.name.clone(), - thread_ts: Some(anchor_ts), + thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) } @@ -1334,9 +1345,10 @@ impl SlackChannel { content: String::new(), channel: self.name.clone(), timestamp: now_secs(), - thread_ts: Some(click.thread_ts), + thread_ts: click.thread_ts, attachments: Vec::new(), selection: Some(ChoiceReply { data: click.value }), + ambient: false, }) } @@ -1514,13 +1526,33 @@ impl Channel for SlackChannel { if !message.attachments.is_empty() { return self.send_with_attachments(message).await; } - self.post_message( - &message.recipient, - message.thread_ts.as_deref(), - &message.content, - &message.options, - ) - .await + let posted = self + .post_message( + &message.recipient, + message.thread_ts.as_deref(), + &message.content, + &message.options, + ) + .await; + if let Err(err) = &posted { + // A slash command in a channel the bot is not in: its reply has + // nowhere to go but the command's own ephemeral response. + if matches!( + api_error_code(err), + Some("not_in_channel" | "channel_not_found") + ) { + let url = self + .slash_response_urls + .lock() + .await + .remove(&message.recipient); + if let Some(url) = url { + self.respond_ephemeral(&url, &anchor_failure_notice(err)) + .await; + } + } + } + posted } async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> { @@ -1546,6 +1578,10 @@ impl Channel for SlackChannel { true } + fn mention_policy(&self) -> MentionPolicy { + self.mention_policy + } + /// Slack has no command menu to fill; it keeps the names so replies can /// point at `!name` instead of a `/name` Slack would swallow. async fn register_commands(&self, cmds: &[CommandSpec]) -> anyhow::Result<()> { diff --git a/crates/ccteam-im/src/transport/providers/slack_tests.rs b/crates/ccteam-im/src/transport/providers/slack_tests.rs index 34923f0a..7f9a9ded 100644 --- a/crates/ccteam-im/src/transport/providers/slack_tests.rs +++ b/crates/ccteam-im/src/transport/providers/slack_tests.rs @@ -389,7 +389,7 @@ fn thread_reply_and_broadcast_thread_on_the_parent_ts() { let mut reply = msg_event(ALLOWED, "C1", "channel", "1700000002.000200", "more"); reply["thread_ts"] = json!("1700000001.000100"); let decoded = decode_message_event(&reply, &bot()).unwrap(); - assert_eq!(decoded.thread_ts, "1700000001.000100"); + assert_eq!(decoded.thread_ts.as_deref(), Some("1700000001.000100")); assert_eq!( decoded.into_channel_message("slack").id, "1700000002.000200" @@ -398,8 +398,39 @@ fn thread_reply_and_broadcast_thread_on_the_parent_ts() { let mut broadcast = reply.clone(); broadcast["subtype"] = json!("thread_broadcast"); assert_eq!( - decode_message_event(&broadcast, &bot()).unwrap().thread_ts, - "1700000001.000100" + decode_message_event(&broadcast, &bot()) + .unwrap() + .thread_ts + .as_deref(), + Some("1700000001.000100") + ); +} + +/// A top-level COMMAND acts at the channel level (no thread): it presets or +/// lists, and the session's thread opens with a real message instead. In a +/// thread a command still belongs to that thread's session. +#[test] +fn a_top_level_command_acts_at_the_channel_level() { + let top = |text: &str| { + decode_message_event(&msg_event(ALLOWED, "C1", "channel", "6.6", text), &bot()) + .unwrap() + .thread_ts + }; + assert_eq!(top("!new codex"), None); + assert_eq!(top(" /projects"), None); + assert_eq!( + top("fix the login bug"), + Some("6.6".to_string()), + "a real message opens its thread" + ); + let mut in_thread = msg_event(ALLOWED, "C1", "channel", "7.7", "!model"); + in_thread["thread_ts"] = json!("6.6"); + assert_eq!( + decode_message_event(&in_thread, &bot()) + .unwrap() + .thread_ts + .as_deref(), + Some("6.6") ); } @@ -498,38 +529,37 @@ fn files_are_collected_and_unreachable_ones_skipped() { } #[test] -fn slash_text_maps_to_a_gateway_command_and_anchor_echoes_it() { +fn slash_text_maps_to_a_gateway_command() { assert_eq!(slash_content(""), "/help"); assert_eq!(slash_content(" "), "/help"); assert_eq!(slash_content("new codex"), "/new codex"); assert_eq!(slash_content(" /status "), "/status"); assert_eq!(slash_content("say a & b"), "/say a & b"); - assert_eq!( - anchor_text("/ccteam", "new codex", "U1"), - "`/ccteam new codex` · <@U1>" - ); - assert_eq!(anchor_text("/ccteam", "", "U1"), "`/ccteam` · <@U1>"); - assert_eq!( - anchor_text("/ccteam", "a <b>", "U1"), - "`/ccteam a <b>` · <@U1>", - "echo stays Slack-escaped" - ); } #[test] fn button_click_threads_on_the_clicked_messages_thread() { let in_thread = decode_block_actions(&click_payload(ALLOWED, "C1", "3.3", Some("1.1"))).unwrap(); - assert_eq!(in_thread.thread_ts, "1.1"); + assert_eq!(in_thread.thread_ts.as_deref(), Some("1.1")); assert_eq!(in_thread.message_ts, "3.3"); assert_eq!(in_thread.value, "tok:1"); let mut via_message = click_payload(ALLOWED, "C1", "3.3", None); via_message["message"]["thread_ts"] = json!("2.2"); - assert_eq!(decode_block_actions(&via_message).unwrap().thread_ts, "2.2"); + assert_eq!( + decode_block_actions(&via_message) + .unwrap() + .thread_ts + .as_deref(), + Some("2.2") + ); let top_level = decode_block_actions(&click_payload(ALLOWED, "C1", "3.3", None)).unwrap(); - assert_eq!(top_level.thread_ts, "3.3", "falls back to the message ts"); + assert_eq!( + top_level.thread_ts, None, + "a top-level menu/list acts at the channel level" + ); let mut not_actions = click_payload(ALLOWED, "C1", "3.3", None); not_actions["type"] = json!("view_submission"); @@ -610,7 +640,7 @@ fn a_dropdown_pick_comes_back_like_a_button_click() { }]); let click = decode_block_actions(&payload).unwrap(); assert_eq!(click.value, "nav:cd:beta"); - assert_eq!(click.thread_ts, "1.1"); + assert_eq!(click.thread_ts.as_deref(), Some("1.1")); } #[test] @@ -696,12 +726,11 @@ fn retry_after_header_is_parsed_and_capped() { // ───────────────────────────────────────────────────────────────────────────── /// The load-bearing inbound contract over a real (local) socket: every -/// envelope is ACKed — the slash command's ACK provably lands BEFORE its -/// anchor post (the mock refuses the post until the ACK arrived) — a -/// re-delivery is deduped, Ping is answered, and every emitted message -/// carries `thread_ts`. +/// envelope is ACKed, a re-delivery is deduped, Ping is answered, and every +/// message carries its thread — except the slash command, which acts at the +/// channel level and posts nothing of its own (no anchor). #[tokio::test] -async fn socket_mode_acks_first_dedupes_and_every_message_carries_thread_ts() { +async fn socket_mode_acks_first_dedupes_and_threads_all_but_channel_commands() { let (ws_listener, ws_url) = ws_server().await; let slash_acked = Arc::new(tokio::sync::Notify::new()); let gate = slash_acked.clone(); @@ -824,8 +853,10 @@ async fn socket_mode_acks_first_dedupes_and_every_message_carries_thread_ts() { let (acks, _ws) = server.await.unwrap(); assert_eq!(acks, vec!["e1", "e2", "e3", "e4", "e5"]); - for m in [&top, &reply, &slash, &click] { + for m in [&top, &reply, &click] { assert!(m.thread_ts.is_some(), "inbound invariant: {m:?}"); + } + for m in [&top, &reply, &slash, &click] { assert_eq!(m.channel, "slack"); assert_eq!(m.reply_target, "C1"); assert_eq!(m.sender, ALLOWED); @@ -836,8 +867,10 @@ async fn socket_mode_acks_first_dedupes_and_every_message_carries_thread_ts() { assert_eq!(reply.id, "1700000002.000200"); assert_eq!(reply.thread_ts.as_deref(), Some("1700000001.000100")); assert_eq!(slash.content, "/new codex"); - assert_eq!(slash.id, "1700000100.000200"); - assert_eq!(slash.thread_ts.as_deref(), Some("1700000100.000200")); + assert_eq!( + slash.thread_ts, None, + "a slash command acts at the channel level" + ); assert_eq!( click.selection, Some(ChoiceReply { @@ -847,12 +880,10 @@ async fn socket_mode_acks_first_dedupes_and_every_message_carries_thread_ts() { assert!(click.content.is_empty()); assert_eq!(click.thread_ts.as_deref(), Some("1700000001.000100")); - let anchor = api.calls("chat.postMessage"); - assert_eq!(anchor.len(), 1); - let body = anchor[0].json(); - assert_eq!(body["channel"], "C1"); - assert_eq!(body["text"], "`/ccteam new codex` · <@UALLOWED>"); - assert!(body.get("thread_ts").is_none(), "the anchor is top-level"); + assert!( + api.calls("chat.postMessage").is_empty(), + "a slash command posts no anchor of its own" + ); assert_eq!( api.calls("auth.test")[0].header("authorization"), Some("Bearer xoxb-test") @@ -1068,8 +1099,10 @@ async fn empty_allowlist_admits_no_one() { assert!(ch.handle_envelope(&click).await.is_none()); } +/// A slash command in a channel the bot is not in: its channel-level reply +/// cannot be posted, so the command's own ephemeral response explains why. #[tokio::test] -async fn slash_command_without_a_postable_anchor_explains_ephemerally_and_drops() { +async fn a_slash_reply_the_bot_cannot_post_is_explained_ephemerally() { let api = MockHttp::start_sync(|req| match req.method() { "chat.postMessage" => api_err("channel_not_found"), "response" => ok(json!({})), @@ -1086,7 +1119,17 @@ async fn slash_command_without_a_postable_anchor_explains_ephemerally_and_drops( &format!("{}/response", api.base), ), ); - assert!(ch.handle_envelope(&slash).await.is_none()); + let message = ch + .handle_envelope(&slash) + .await + .expect("handled at channel level"); + assert!(ch + .send(&SendMessage::new( + "⚙️ 下一个会话:codex", + message.reply_target + )) + .await + .is_err()); let replies = api.calls("response"); assert_eq!(replies.len(), 1); let body = replies[0].json(); @@ -1096,7 +1139,7 @@ async fn slash_command_without_a_postable_anchor_explains_ephemerally_and_drops( /// Each app declares its own command (`/cct2` for an app named cct2) and /// Socket Mode only routes an app its own commands, so whatever command -/// arrives is this app's: it opens an anchor thread like `/ccteam` does. +/// arrives is this app's — handled at the channel level like `/ccteam`. #[tokio::test] async fn an_apps_own_slash_command_works_whatever_it_is_named() { let api = MockHttp::start_sync(default_api).await; @@ -1108,13 +1151,8 @@ async fn an_apps_own_slash_command_works_whatever_it_is_named() { .await .expect("the app's own command is handled"); assert_eq!(message.content, "/projects"); - let posts = api.calls("chat.postMessage"); - assert_eq!(posts.len(), 1, "one anchor message"); - assert!(posts[0].json()["text"] - .as_str() - .unwrap() - .contains("/cct2 projects")); - assert_eq!(message.thread_ts.as_deref(), Some("1700000999.000100")); + assert_eq!(message.thread_ts, None); + assert!(api.calls("chat.postMessage").is_empty(), "no anchor"); } // ───────────────────────────────────────────────────────────────────────────── @@ -1590,3 +1628,167 @@ fn button_weight_maps_to_style_and_a_confirm_step() { assert_eq!(buttons[2]["confirm"]["confirm"]["text"], "确定"); assert_eq!(buttons[2]["confirm"]["deny"]["text"], "取消"); } + +// ───────────────────────────────────────────────────────────────────────────── +// `require_mention` — channel chatter vs. a message that addresses the bot +// ───────────────────────────────────────────────────────────────────────────── + +/// "Ambient" is a channel / private-channel / group-DM message that does not +/// @-mention the bot; a DM never is. The fact is reported either way — the +/// policy, not the decoder, decides what to do with it. +#[test] +fn channel_messages_are_ambient_until_they_mention_the_bot() { + for channel_type in ["channel", "group", "mpim"] { + let plain = msg_event(ALLOWED, "C1", channel_type, "1.1", "anyone around?"); + let d = decode_message_event(&plain, &bot()).unwrap(); + assert!(d.ambient, "{channel_type}: no mention is ambient"); + assert!(!d.in_thread, "a top-level message only opens a thread"); + + let addressed = msg_event(ALLOWED, "C1", channel_type, "1.2", "<@UBOT> status?"); + let d = decode_message_event(&addressed, &bot()).unwrap(); + assert!(!d.ambient, "{channel_type}: an @-mention addresses the bot"); + + // Mentioning someone else addresses someone else. + let other = msg_event(ALLOWED, "C1", channel_type, "1.3", "<@UOTHER> status?"); + assert!(decode_message_event(&other, &bot()).unwrap().ambient); + } + let dm = msg_event(ALLOWED, "D1", "im", "2.1", "hi"); + assert!(!decode_message_event(&dm, &bot()).unwrap().ambient); + + let mut reply = msg_event(ALLOWED, "C1", "channel", "3.2", "and another thing"); + reply["thread_ts"] = json!("3.1"); + let d = decode_message_event(&reply, &bot()).unwrap(); + assert!( + d.ambient && d.in_thread, + "a thread reply is still ambient chatter" + ); +} + +/// Default policy: everything an allowed member says is delivered; the +/// ambient fact rides along for the daemon. +#[tokio::test] +async fn by_default_ambient_channel_messages_are_delivered() { + let api = MockHttp::start_sync(default_api).await; + let ch = channel(&api.base, &[ALLOWED]); + assert!(!ch.mention_policy().require_mention); + let msg = ch + .handle_envelope(&envelope( + "events_api", + event_payload("Ev1", msg_event(ALLOWED, "C1", "channel", "1.1", "chatter")), + )) + .await + .expect("answered by default"); + assert!(msg.ambient); +} + +/// `require_mention`: ambient top-level chatter is dropped before anything is +/// downloaded; a mention, a DM and a thread reply (the daemon decides whether +/// that thread is held) get through. +#[tokio::test] +async fn require_mention_drops_ambient_chatter_before_downloading_its_files() { + let api = MockHttp::start_sync(|req| match req.path.as_str() { + "/files/F1/shot.png" => Resp { + status: 200, + headers: vec![("Content-Type".into(), "image/png".into())], + body: b"\x89PNG".to_vec(), + }, + _ => default_api(req), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let ch = channel(&api.base, &[ALLOWED]) + .with_require_mention(true) + .with_staging_dir(tmp.path().to_path_buf()); + assert!(ch.mention_policy().require_mention); + + // Ambient top-level chatter WITH a file: dropped, file never fetched. + let mut chatter = msg_event(ALLOWED, "C1", "channel", "1.1", "look at this"); + chatter["subtype"] = json!("file_share"); + chatter["files"] = json!([ + { "id": "F1", "name": "shot.png", "mimetype": "image/png", "size": 4, + "url_private_download": format!("{}/files/F1/shot.png", api.base) }, + ]); + assert!(ch + .handle_envelope(&envelope("events_api", event_payload("Ev1", chatter))) + .await + .is_none()); + assert!( + api.requests() + .iter() + .all(|r| !r.path.starts_with("/files/")), + "a dropped message must not cost a download" + ); + + // Addressed: delivered, file and all. + let mut mention = msg_event(ALLOWED, "C1", "channel", "2.1", "<@UBOT> look at this"); + mention["subtype"] = json!("file_share"); + mention["files"] = json!([ + { "id": "F1", "name": "shot.png", "mimetype": "image/png", "size": 4, + "url_private_download": format!("{}/files/F1/shot.png", api.base) }, + ]); + let msg = ch + .handle_envelope(&envelope("events_api", event_payload("Ev2", mention))) + .await + .expect("a mention is answered"); + assert!(!msg.ambient); + assert_eq!(msg.attachments.len(), 1); + + // A DM is never ambient. + let dm = ch + .handle_envelope(&envelope( + "events_api", + event_payload("Ev3", msg_event(ALLOWED, "D1", "im", "3.1", "hi")), + )) + .await + .expect("a DM is answered"); + assert!(!dm.ambient); + + // An ambient reply inside a thread goes on to the daemon, which knows + // whether the gateway holds that thread. + let mut reply = msg_event(ALLOWED, "C1", "channel", "4.2", "and another thing"); + reply["thread_ts"] = json!("2.1"); + let msg = ch + .handle_envelope(&envelope("events_api", event_payload("Ev4", reply))) + .await + .expect("a thread reply is left to the daemon"); + assert!(msg.ambient); + assert_eq!(msg.thread_ts.as_deref(), Some("2.1")); + + // Slash commands and button clicks address the app by construction. + let click = ch + .handle_envelope(&envelope( + "interactive", + click_payload(ALLOWED, "C1", "5.5", Some("2.1")), + )) + .await + .expect("a click is answered"); + assert!(!click.ambient); +} + +/// The allowlist still runs first: an ambient message from a stranger leaves +/// its probe (the web binding flow reads it) even though it is also dropped. +#[tokio::test] +async fn require_mention_does_not_hide_strangers_from_the_binding_probe() { + let api = MockHttp::start_sync(default_api).await; + let tmp = tempfile::tempdir().unwrap(); + let probe = tmp.path().join("probe.jsonl"); + let ch = channel(&api.base, &[ALLOWED]) + .with_require_mention(true) + .with_probe_path(probe.clone()); + assert!(ch + .handle_envelope(&envelope( + "events_api", + event_payload( + "Ev1", + msg_event("USTRANGER", "C1", "channel", "1.1", "hello") + ), + )) + .await + .is_none()); + assert!( + std::fs::read_to_string(&probe) + .unwrap_or_default() + .contains("USTRANGER"), + "the rejected sender is recorded" + ); +} diff --git a/crates/ccteam-im/src/transport/providers/telegram.rs b/crates/ccteam-im/src/transport/providers/telegram.rs index 3bf9242f..2d951d17 100644 --- a/crates/ccteam-im/src/transport/providers/telegram.rs +++ b/crates/ccteam-im/src/transport/providers/telegram.rs @@ -14,15 +14,15 @@ use std::time::{Duration, Instant}; use async_trait::async_trait; use serde::Deserialize; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, OnceCell}; use anyhow::Context as _; use crate::latency::now_unix_ms; use crate::transport::{ inbound_staging_dir, sanitize_attachment_name, AttachmentKind, Channel, ChannelAttachment, - ChannelMessage, ChoiceReply, CommandSpec, MessageOption, OptionWeight, OutboundFile, - OutboundFileKind, RejectedSenderNotifier, RejectedSenderProbe, SendMessage, + ChannelMessage, ChoiceReply, CommandSpec, MentionPolicy, MessageOption, OptionWeight, + OutboundFile, OutboundFileKind, RejectedSenderNotifier, RejectedSenderProbe, SendMessage, }; /// `getUpdates` long-poll seconds. @@ -51,11 +51,24 @@ pub struct TelegramChannel { open_when_unset: bool, /// Shared setup probe + one-shot binding notice for rejected senders. rejected_senders: RejectedSenderNotifier, + /// Whether a group message must @-mention the bot to be answered. + mention_policy: MentionPolicy, + /// `getMe`, fetched on the first group message (a private chat needs no + /// identity) and kept once it succeeds. + bot: OnceCell, http: reqwest::Client, last_offset: Arc>, name: String, } +/// The bot's own identity from `getMe`: its id (a reply to one of its +/// messages, a `text_mention`) and username (an `@bot` mention, `/cmd@bot`). +#[derive(Debug, Clone, PartialEq, Eq)] +struct BotIdentity { + id: i64, + username: String, +} + impl TelegramChannel { /// Build with the @BotFather token and allowed chat IDs (empty = /// open). The chat-ID allowlist is enforced inside [`Channel::listen`] @@ -66,6 +79,8 @@ impl TelegramChannel { allowed_chat_ids, open_when_unset: true, rejected_senders: RejectedSenderNotifier::default(), + mention_policy: MentionPolicy::default(), + bot: OnceCell::new(), http: reqwest::Client::builder() .timeout(Duration::from_secs(POLL_TIMEOUT_SECS + 10)) .build() @@ -83,6 +98,39 @@ impl TelegramChannel { self } + /// Answer a group message only when it @-mentions the bot (or replies to + /// it) — the credentials' `require_mention`. Private chats, commands' + /// buttons and clicks are unaffected. + pub fn with_require_mention(mut self, require_mention: bool) -> Self { + self.mention_policy = MentionPolicy { require_mention }; + self + } + + /// The bot's own identity (`getMe`), cached after the first success. A + /// failure is logged and retried by the next group message. + async fn bot_identity(&self) -> Option<&BotIdentity> { + self.bot + .get_or_try_init(|| async { + let resp: GetMeResp = self + .http + .get(self.api_url("getMe")) + .send() + .await? + .json() + .await?; + match resp.result { + Some(me) if resp.ok => Ok(BotIdentity { + id: me.id, + username: me.username.unwrap_or_default(), + }), + _ => anyhow::bail!("getMe ok=false"), + } + }) + .await + .map_err(|err| tracing::warn!(error = %err, "telegram getMe failed")) + .ok() + } + /// Treat an EMPTY allowlist as "answer nobody" instead of "answer /// everybody" — the posture every per-tenant bot takes (see /// [`Self::open_when_unset`]). @@ -242,6 +290,7 @@ impl TelegramChannel { thread_ts: None, attachments: Vec::new(), selection: Some(ChoiceReply { data }), + ambient: false, }; let _ = tx.send(payload).await; } @@ -388,11 +437,50 @@ struct TgMessage { document: Option, #[serde(default)] caption: Option, + /// Markup spans of `text` (an `@bot` mention, a `/cmd@bot` command). + #[serde(default)] + entities: Vec, + /// Markup spans of `caption` (a media message's text). + #[serde(default)] + caption_entities: Vec, + /// The message this one replies to; replying to the bot addresses it. + #[serde(default)] + reply_to_message: Option>, } #[derive(Debug, Deserialize)] struct TgChat { id: i64, + /// `private` | `group` | `supergroup` | `channel`; absent ⇒ not a group. + #[serde(default, rename = "type")] + kind: String, +} + +/// The only part of a replied-to message the gateway cares about: who sent it. +#[derive(Debug, Deserialize)] +struct TgReplied { + #[serde(default)] + from: Option, +} + +/// One markup span; `offset`/`length` count UTF-16 code units. +#[derive(Debug, Deserialize)] +struct TgEntity { + #[serde(rename = "type")] + kind: String, + offset: usize, + length: usize, + /// Set on a `text_mention` (a mention of a user with no username). + #[serde(default)] + user: Option, +} + +/// `getMe` response. +#[derive(Debug, Deserialize)] +struct GetMeResp { + ok: bool, + #[serde(default)] + result: Option, } #[derive(Debug, Deserialize)] @@ -436,6 +524,92 @@ struct PendingDownload { size: Option, } +/// The text a message carries and the markup spans that index into it (a media +/// message's text is its caption). +fn text_and_entities(m: &TgMessage) -> (&str, &[TgEntity]) { + match m.text.as_deref() { + Some(text) => (text, &m.entities), + None => (m.caption.as_deref().unwrap_or(""), &m.caption_entities), + } +} + +/// `text[offset..offset+len]` where offset/len count UTF-16 code units (the +/// unit Telegram's entities use); `None` when the span is out of range. +fn utf16_span(text: &str, offset: usize, len: usize) -> Option { + let units: Vec = text.encode_utf16().collect(); + String::from_utf16(units.get(offset..offset.checked_add(len)?)?).ok() +} + +/// Whether `m` is a group / supergroup message (not a private chat). +fn is_group(m: &TgMessage) -> bool { + matches!(m.chat.kind.as_str(), "group" | "supergroup") +} + +/// Whether `username` (an `@handle` without its `@`) is the bot's — Telegram +/// usernames are case-insensitive. +fn names_bot(username: Option<&str>, bot: &BotIdentity) -> bool { + username.is_some_and(|who| !who.is_empty() && who.eq_ignore_ascii_case(&bot.username)) +} + +/// Whether a group message addresses the bot: it @-mentions it (`@bot`, or a +/// `text_mention` of its id), is a command aimed at it (`/cmd@bot`), or +/// replies to one of its messages. +fn addresses_bot(m: &TgMessage, bot: &BotIdentity) -> bool { + let replies_to_bot = m + .reply_to_message + .as_ref() + .and_then(|r| r.from.as_ref()) + .is_some_and(|u| u.id == bot.id); + if replies_to_bot { + return true; + } + let (text, entities) = text_and_entities(m); + entities.iter().any(|e| match e.kind.as_str() { + "text_mention" => e.user.as_ref().is_some_and(|u| u.id == bot.id), + "mention" => utf16_span(text, e.offset, e.length) + .is_some_and(|s| names_bot(s.strip_prefix('@'), bot)), + "bot_command" => utf16_span(text, e.offset, e.length) + .is_some_and(|s| names_bot(s.rsplit_once('@').map(|(_, who)| who), bot)), + _ => false, + }) +} + +/// The group message's text with the bot's own handle removed — `@bot hi` → +/// `hi`, `/status@bot` → `/status` — so addressing the bot never stops a +/// command from being a command (Slack strips `<@BOT>` the same way). +fn without_bot_handle(m: &TgMessage, bot: &BotIdentity) -> String { + let (text, entities) = text_and_entities(m); + let handle_units = 1 + bot.username.encode_utf16().count(); + let mut cuts: Vec<(usize, usize)> = entities + .iter() + .filter_map(|e| { + let span = utf16_span(text, e.offset, e.length)?; + match e.kind.as_str() { + "mention" if names_bot(span.strip_prefix('@'), bot) => { + Some((e.offset, e.offset + e.length)) + } + "bot_command" if names_bot(span.rsplit_once('@').map(|(_, who)| who), bot) => { + Some((e.offset + e.length - handle_units, e.offset + e.length)) + } + _ => None, + } + }) + .collect(); + if cuts.is_empty() { + return text.to_string(); + } + cuts.sort_unstable(); + let units: Vec = text.encode_utf16().collect(); + let mut kept = Vec::with_capacity(units.len()); + let mut at = 0; + for (start, end) in cuts { + kept.extend_from_slice(&units[at..start.max(at)]); + at = end.max(at); + } + kept.extend_from_slice(&units[at..]); + String::from_utf16_lossy(&kept).trim().to_string() +} + /// Pure: choose the single attachment to download for a message — /// a document (preferred, has a real name) or the largest photo size. /// Returns `None` for a text-only message. @@ -610,6 +784,22 @@ impl Channel for TelegramChannel { self.reject_chat(&chat_id, m.message_id, m.date).await; continue; } + // A group message that does not address the bot is + // "ambient"; a private chat never is. Telegram has no + // threads, so under `require_mention` it is dropped here, + // before any attachment download. (Identity unknown ⇒ + // cannot tell a mention, so it counts as ambient.) + let group = is_group(&m); + let bot = if group { + self.bot_identity().await + } else { + None + }; + let ambient = group && !bot.is_some_and(|b| addresses_bot(&m, b)); + if self.mention_policy.drops_early(ambient, false) { + tracing::debug!(chat_id = %chat_id, "telegram: ambient group message dropped (require_mention)"); + continue; + } let sender = m .from .as_ref() @@ -625,11 +815,14 @@ impl Channel for TelegramChannel { let tg_date_ms = (m.date.max(0) as u128).saturating_mul(1000); let tg_age_ms = recv_ms.saturating_sub(tg_date_ms); // V0.8.4 P2a — caption is the text for media messages. - let content = m - .text - .clone() - .or_else(|| m.caption.clone()) - .unwrap_or_default(); + let content = match bot { + Some(bot) if group => without_bot_handle(&m, bot), + _ => m + .text + .clone() + .or_else(|| m.caption.clone()) + .unwrap_or_default(), + }; let mut attachments = Vec::new(); let mut rejected_notice: Option = None; if let Some(pending) = pick_attachment(&m) { @@ -685,6 +878,7 @@ impl Channel for TelegramChannel { thread_ts: None, attachments, selection: None, + ambient, }; if tx.send(payload).await.is_err() { return Ok(()); @@ -710,6 +904,10 @@ impl Channel for TelegramChannel { true } + fn mention_policy(&self) -> MentionPolicy { + self.mention_policy + } + async fn edit_message( &self, recipient: &str, @@ -1059,6 +1257,193 @@ mod tests { ); } + fn tg(msg: serde_json::Value) -> TgMessage { + let mut base = serde_json::json!({"message_id": 1, "date": 0}); + base.as_object_mut() + .unwrap() + .extend(msg.as_object().unwrap().clone()); + serde_json::from_value(base).unwrap() + } + + fn me() -> BotIdentity { + BotIdentity { + id: 777, + username: "CctBot".into(), + } + } + + /// A private chat is never a group; group and supergroup are; a message + /// whose chat type is absent counts as not-a-group (answer, don't gate). + #[test] + fn only_group_and_supergroup_are_groups() { + for (kind, group) in [ + ("private", false), + ("group", true), + ("supergroup", true), + ("channel", false), + ] { + let m = tg(serde_json::json!({"chat": {"id": 5, "type": kind}, "text": "hi"})); + assert_eq!(is_group(&m), group, "{kind}"); + } + assert!(!is_group(&tg(serde_json::json!({"chat": {"id": 5}})))); + } + + /// What addresses the bot in a group: an @-mention (any case — usernames + /// are case-insensitive), a `text_mention` of its id, `/cmd@bot`, a reply + /// to one of its messages. Another bot's handle and a plain `/cmd` do not. + #[test] + fn a_group_message_addresses_the_bot_by_mention_command_or_reply() { + let group = |extra: serde_json::Value| { + let mut m = serde_json::json!({"chat": {"id": -9, "type": "supergroup"}}); + m.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + tg(m) + }; + let mention = group(serde_json::json!({ + "text": "@cctbot what is the status", + "entities": [{"type": "mention", "offset": 0, "length": 7}], + })); + assert!(addresses_bot(&mention, &me()), "case-insensitive @mention"); + + let other_bot = group(serde_json::json!({ + "text": "@OtherBot hello", + "entities": [{"type": "mention", "offset": 0, "length": 9}], + })); + assert!(!addresses_bot(&other_bot, &me())); + + let text_mention = group(serde_json::json!({ + "text": "hey you", + "entities": [{"type": "text_mention", "offset": 4, "length": 3, + "user": {"id": 777}}], + })); + assert!(addresses_bot(&text_mention, &me())); + + let command = group(serde_json::json!({ + "text": "/status@CctBot", + "entities": [{"type": "bot_command", "offset": 0, "length": 14}], + })); + assert!(addresses_bot(&command, &me())); + let bare_command = group(serde_json::json!({ + "text": "/status", + "entities": [{"type": "bot_command", "offset": 0, "length": 7}], + })); + assert!( + !addresses_bot(&bare_command, &me()), + "a bare /cmd goes to every bot in the group" + ); + + let reply = group(serde_json::json!({ + "text": "thanks", + "reply_to_message": {"from": {"id": 777}}, + })); + assert!(addresses_bot(&reply, &me())); + let reply_to_human = group(serde_json::json!({ + "text": "thanks", + "reply_to_message": {"from": {"id": 1}}, + })); + assert!(!addresses_bot(&reply_to_human, &me())); + + // A media message carries its @-mention in the caption entities. + let caption = group(serde_json::json!({ + "caption": "@CctBot see this", + "caption_entities": [{"type": "mention", "offset": 0, "length": 7}], + "photo": [{"file_id": "p", "file_size": 1}], + })); + assert!(addresses_bot(&caption, &me())); + assert!(!addresses_bot( + &group(serde_json::json!({"text": "hi"})), + &me() + )); + } + + /// Entity offsets count UTF-16 units: an emoji before the mention shifts + /// it by two, and a malformed span never panics. + #[test] + fn entity_offsets_are_utf16_units() { + let m = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "🙂 @CctBot hi", + "entities": [{"type": "mention", "offset": 3, "length": 7}], + })); + assert!(addresses_bot(&m, &me())); + assert_eq!(without_bot_handle(&m, &me()), "🙂 hi"); + let bad = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "hi", + "entities": [{"type": "mention", "offset": 40, "length": 7}], + })); + assert!(!addresses_bot(&bad, &me())); + assert_eq!(without_bot_handle(&bad, &me()), "hi"); + } + + /// A command aimed at ANOTHER bot, or a span shorter than this bot's + /// handle, is not ours: it must neither count as addressing us nor be cut + /// (the cut arithmetic would underflow on a span shorter than the handle). + #[test] + fn a_command_for_another_bot_is_left_alone() { + let long_bot = BotIdentity { + id: 777, + username: "longbot".into(), + }; + let m = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "/x@b hi", + "entities": [{"type": "bot_command", "offset": 0, "length": 4}], + })); + assert!(!addresses_bot(&m, &long_bot)); + assert_eq!(without_bot_handle(&m, &long_bot), "/x@b hi"); + let other = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "/status@otherbot", + "entities": [{"type": "bot_command", "offset": 0, "length": 16}], + })); + assert!(!addresses_bot(&other, &me())); + assert_eq!(without_bot_handle(&other, &me()), "/status@otherbot"); + } + + /// Addressing the bot must never stop a command from being a command: + /// the bot's own handle is removed, anyone else's stays. + #[test] + fn the_bots_own_handle_is_stripped_so_commands_still_parse() { + let mention = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "@CctBot /new codex", + "entities": [{"type": "mention", "offset": 0, "length": 7}, + {"type": "bot_command", "offset": 8, "length": 4}], + })); + assert_eq!(without_bot_handle(&mention, &me()), "/new codex"); + let suffix = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "/status@cctbot now", + "entities": [{"type": "bot_command", "offset": 0, "length": 14}], + })); + assert_eq!(without_bot_handle(&suffix, &me()), "/status now"); + let other = tg(serde_json::json!({ + "chat": {"id": -9, "type": "group"}, + "text": "@alice @CctBot ping", + "entities": [{"type": "mention", "offset": 0, "length": 6}, + {"type": "mention", "offset": 7, "length": 7}], + })); + assert_eq!(without_bot_handle(&other, &me()), "@alice ping"); + } + + /// The builder switch reaches the trait, and the default answers all. + #[test] + fn require_mention_reaches_the_mention_policy() { + assert!( + !TelegramChannel::new("t".into(), vec![]) + .mention_policy() + .require_mention + ); + assert!( + TelegramChannel::new("t".into(), vec![]) + .with_require_mention(true) + .mention_policy() + .require_mention + ); + } + #[test] fn pick_attachment_none_for_text_only() { let m: TgMessage = serde_json::from_value(serde_json::json!({ diff --git a/crates/ccteam-im/src/transport/providers/ws.rs b/crates/ccteam-im/src/transport/providers/ws.rs index 2cfb15ca..4b54cc52 100644 --- a/crates/ccteam-im/src/transport/providers/ws.rs +++ b/crates/ccteam-im/src/transport/providers/ws.rs @@ -279,6 +279,7 @@ fn parse_frame(frame: Message, peer: SocketAddr) -> anyhow::Result) -> Arc { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; } diff --git a/crates/ccteam-im/tests/inbound_wiring_test.rs b/crates/ccteam-im/tests/inbound_wiring_test.rs index 5ff61567..0df567cb 100644 --- a/crates/ccteam-im/tests/inbound_wiring_test.rs +++ b/crates/ccteam-im/tests/inbound_wiring_test.rs @@ -481,6 +481,7 @@ async fn daemon_wires_mock_channel_to_supervisor_inbox() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; @@ -575,6 +576,7 @@ async fn daemon_routes_gateway_inbound_to_submit_turn_and_outbound() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -587,6 +589,7 @@ async fn daemon_routes_gateway_inbound_to_submit_turn_and_outbound() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; @@ -725,6 +728,7 @@ async fn run_two_thread_conversation(threaded: bool) -> (usize, Vec<(String, Opt thread_ts: Some(thread.into()), attachments: Vec::new(), selection: None, + ambient: false, }) .await; } @@ -812,6 +816,119 @@ async fn daemon_scopes_sessions_to_threads_only_on_a_threading_channel() { } } +/// One `require_mention` daemon run: three messages in one conversation on a +/// threading channel — chatter in a thread nobody addressed, an @-mention +/// that opens thread `100.1`, and an un-mentioned follow-up in that same +/// thread — with the channel's policy on or off. Returns `(spawns, answers)` +/// where `answers` are the echoed texts. +async fn run_ambient_conversation(require_mention: bool) -> (usize, Vec) { + let home = isolate_home(); + let projects_root = home.path().join("projects"); + std::fs::create_dir_all(&projects_root).unwrap(); + + let mock = Arc::new(if require_mention { + MockChannel::new() + .with_session_threads() + .with_require_mention() + } else { + MockChannel::new().with_session_threads() + }); + for (id, thread, ambient, text) in [ + ("a-1", "200.2", true, "ambient chatter"), + ("a-2", "100.1", false, "hey bot"), + ("a-3", "100.1", true, "follow up without a mention"), + ] { + mock.push(ChannelMessage { + id: id.into(), + sender: "alice".into(), + reply_target: "conv-1".into(), + content: text.into(), + channel: "telegram".into(), + timestamp: 0, + thread_ts: Some(thread.into()), + attachments: Vec::new(), + selection: None, + ambient, + }) + .await; + } + let mut channels: ChannelMap = std::collections::HashMap::new(); + channels.insert( + "telegram".to_string(), + mock.clone() as Arc, + ); + let spawned: Arc>>> = Arc::default(); + let adapter_factory: AdapterFactory = { + let spawned = Arc::clone(&spawned); + Arc::new(move |_, _| { + let adapter = Arc::new(GatewayAdapter::default()); + spawned.lock().unwrap().push(Arc::clone(&adapter)); + adapter as Arc + }) + }; + let args = DaemonArgs { + credentials: None, + registry: Some(projects_root), + max_runtime: Some(Duration::from_millis(1200)), + adapter_factory: Some(adapter_factory), + channels_override: Some(channels), + extra_channels: None, + ..Default::default() + }; + run_daemon_with_shutdown(args, async { + futures::future::pending::<()>().await; + }) + .await + .unwrap(); + + let spawns = spawned + .lock() + .unwrap() + .iter() + .map(|adapter| adapter.starts.load(Ordering::SeqCst)) + .sum(); + let answers = mock + .outbox() + .await + .into_iter() + .filter_map(|m| m.content.strip_prefix("gateway echo: ").map(str::to_string)) + .collect(); + (spawns, answers) +} + +/// `require_mention` (every IM's group-reply switch) is applied ONCE, in the +/// inbound consumer, from the provider's `ambient` fact: off, every message is +/// answered; on, chatter nobody addressed is dropped, while a thread the bot +/// was addressed in carries on without a mention — including a follow-up that +/// arrives while that thread's session is still being spawned. +#[allow(clippy::await_holding_lock)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn daemon_answers_ambient_messages_only_in_threads_it_was_addressed_in() { + let _g = env_lock(); + + let (spawns, answers) = run_ambient_conversation(false).await; + assert_eq!(spawns, 2, "off: every thread gets a session"); + assert_eq!(answers.len(), 3, "off: every message answered: {answers:?}"); + + let (spawns, answers) = run_ambient_conversation(true).await; + assert_eq!(spawns, 1, "on: only the addressed thread gets a session"); + assert_eq!(answers.len(), 2, "on: {answers:?}"); + assert!( + answers.iter().any(|a| a.starts_with("hey bot")), + "the @-mention is answered: {answers:?}" + ); + assert!( + answers + .iter() + .any(|a| a.starts_with("follow up without a mention")), + "an un-mentioned follow-up in the addressed thread is answered: {answers:?}" + ); + assert!( + answers.iter().all(|a| !a.starts_with("ambient chatter")), + "chatter in an unaddressed thread is dropped: {answers:?}" + ); +} + /// V0.8.4 P0 — a gateway reply that overflows the channel's /// `max_message_len` is split into ordered durable sub-messages. Built on /// `daemon_routes_gateway_inbound_to_submit_turn_and_outbound`, but with a @@ -842,6 +959,7 @@ async fn daemon_splits_long_outbound_into_ordered_parts() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -854,6 +972,7 @@ async fn daemon_splits_long_outbound_into_ordered_parts() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; @@ -1006,6 +1125,7 @@ async fn daemon_split_failure_surfaces_notice() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -1018,6 +1138,7 @@ async fn daemon_split_failure_surfaces_notice() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; @@ -1221,6 +1342,7 @@ async fn daemon_surfaces_start_failure_to_im_and_ledger() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; let adapter = Arc::new(FailingGatewayAdapter::new(true, false)); @@ -1262,6 +1384,7 @@ async fn daemon_surfaces_submit_failure_to_im_and_ledger() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -1274,6 +1397,7 @@ async fn daemon_surfaces_submit_failure_to_im_and_ledger() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; let adapter = Arc::new(FailingGatewayAdapter::new(false, true)); @@ -1331,6 +1455,7 @@ async fn first_activation_probes_the_tool_face_once_per_session() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; } @@ -1516,6 +1641,7 @@ async fn pump_reattaches_after_the_inbound_stream_ends() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; let adapter = Arc::new(DetachingAdapter::new(true)); @@ -1596,6 +1722,7 @@ async fn detached_stream_closes_the_turn_it_swallowed() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; } @@ -1783,6 +1910,7 @@ async fn watchdog_does_not_interrupt_a_streaming_turn() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; } @@ -1830,6 +1958,7 @@ async fn daemon_surfaces_turn_timeout_to_im_and_ledger() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -1842,6 +1971,7 @@ async fn daemon_surfaces_turn_timeout_to_im_and_ledger() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; let adapter = Arc::new(FailingGatewayAdapter::new(false, false)); @@ -2947,6 +3077,7 @@ async fn daemon_routes_inbound_image_attachment_into_turn_text() { thread_ts: None, attachments: Vec::new(), selection: None, + ambient: false, }) .await; mock.push(ChannelMessage { @@ -2965,6 +3096,7 @@ async fn daemon_routes_inbound_image_attachment_into_turn_text() { size: Some(1234), }], selection: None, + ambient: false, }) .await; diff --git a/crates/ccteam-web/src/routes/im_config.rs b/crates/ccteam-web/src/routes/im_config.rs index 92a55b11..6fd9d64d 100644 --- a/crates/ccteam-web/src/routes/im_config.rs +++ b/crates/ccteam-web/src/routes/im_config.rs @@ -127,7 +127,7 @@ fn lark_api_base(use_feishu: bool) -> String { /// Resolve the Slack Web API base: the [`SLACK_API_BASE_ENV`] override if set /// + non-empty, else the production constant. -fn slack_api_base() -> String { +pub(crate) fn slack_api_base() -> String { std::env::var(SLACK_API_BASE_ENV) .ok() .filter(|s| !s.is_empty()) @@ -168,6 +168,12 @@ pub struct TelegramStatus { pub bot_token_last4: String, /// How many `chat_id`s are bound (the allowlist length). pub chat_id_count: usize, + /// The bound `chat_id`s themselves (not secrets) — an allowlist PUT + /// replaces the whole list, so an editor must start from what is saved. + pub allowed_chat_ids: Vec, + /// Whether a group chat / channel message must @-mention the bot to be + /// answered (`false` = every message from an allowed sender is). + pub require_mention: bool, } /// Masked Lark/Feishu status. Note the absence of any `app_secret` field. @@ -182,6 +188,12 @@ pub struct LarkStatus { pub use_feishu: bool, /// How many `open_id`s are allowlisted. pub allowed_user_id_count: usize, + /// The allowlisted `open_id`s themselves (not secrets), for the same + /// replace-the-whole-list reason as [`TelegramStatus::allowed_chat_ids`]. + pub allowed_user_ids: Vec, + /// Whether a group chat / channel message must @-mention the bot to be + /// answered (`false` = every message from an allowed sender is). + pub require_mention: bool, } /// Masked Slack status. Note the absence of any `bot_token` / `app_token` @@ -197,6 +209,9 @@ pub struct SlackStatus { pub app_token_last4: String, /// The Slack user ids allowed to drive the bot (empty = nobody). pub allowed_user_ids: Vec, + /// Whether a group chat / channel message must @-mention the bot to be + /// answered (`false` = every message from an allowed sender is). + pub require_mention: bool, } /// `GET /config/im` response — masked, secret-free. @@ -344,30 +359,91 @@ pub(crate) async fn handle_get_im_config( Ok(c) => c, Err(e) => return json_500(e), }; - let telegram = creds.telegram.map(|t| TelegramStatus { + Json(ImConfigStatus { + telegram: creds + .telegram + .map(|t| telegram_status(&t.bot_token, t.allowed_chat_ids, t.require_mention)), + lark: creds.lark.map(|l| { + lark_status( + &l.app_id, + l.use_feishu, + l.allowed_user_ids, + l.require_mention, + ) + }), + slack: creds.slack.map(|s| { + slack_status( + &s.bot_token, + &s.app_token, + s.allowed_user_ids, + s.require_mention, + ) + }), + transport_warning: TRANSPORT_WARNING.to_string(), + }) + .into_response() +} + +/// The masked per-IM status builders — shared by the owner's global read here +/// and a tenant's own read ([`super::users`]), so both surfaces report the same +/// shape from the one place that knows what is secret. +pub(crate) fn telegram_status( + bot_token: &str, + allowed_chat_ids: Vec, + require_mention: bool, +) -> TelegramStatus { + TelegramStatus { configured: true, - bot_token_last4: mask_last4(&t.bot_token), - chat_id_count: t.allowed_chat_ids.len(), - }); - let lark = creds.lark.map(|l| LarkStatus { + bot_token_last4: mask_last4(bot_token), + chat_id_count: allowed_chat_ids.len(), + allowed_chat_ids, + require_mention, + } +} + +pub(crate) fn lark_status( + app_id: &str, + use_feishu: bool, + allowed_user_ids: Vec, + require_mention: bool, +) -> LarkStatus { + LarkStatus { configured: true, - app_id_last4: mask_last4(&l.app_id), - use_feishu: l.use_feishu, - allowed_user_id_count: l.allowed_user_ids.len(), - }); - let slack = creds.slack.map(|s| SlackStatus { + app_id_last4: mask_last4(app_id), + use_feishu, + allowed_user_id_count: allowed_user_ids.len(), + allowed_user_ids, + require_mention, + } +} + +pub(crate) fn slack_status( + bot_token: &str, + app_token: &str, + allowed_user_ids: Vec, + require_mention: bool, +) -> SlackStatus { + SlackStatus { configured: true, - bot_token_last4: mask_last4(&s.bot_token), - app_token_last4: mask_last4(&s.app_token), - allowed_user_ids: s.allowed_user_ids, - }); - Json(ImConfigStatus { + bot_token_last4: mask_last4(bot_token), + app_token_last4: mask_last4(app_token), + allowed_user_ids, + require_mention, + } +} + +/// Wrap the three per-IM statuses into the `GET /config/im` / `GET /me/im` body. +pub(crate) fn im_status( + telegram: Option, + lark: Option, + slack: Option, +) -> ImConfigStatus { + ImConfigStatus { telegram, lark, slack, transport_warning: TRANSPORT_WARNING.to_string(), - }) - .into_response() + } } // -------------------------------------------------------------------------- @@ -414,14 +490,15 @@ pub(crate) async fn handle_put_telegram( Ok(c) => c, Err(e) => return json_500(e), }; - let existing_chat_ids = creds - .telegram - .take() - .map(|t| t.allowed_chat_ids) - .unwrap_or_default(); + let existing = creds.telegram.take(); creds.telegram = Some(TelegramCreds { bot_token: token.to_string(), - allowed_chat_ids: existing_chat_ids, + allowed_chat_ids: existing + .as_ref() + .map(|t| t.allowed_chat_ids.clone()) + .unwrap_or_default(), + // A token change must not reset the group-reply switch. + require_mention: existing.is_some_and(|t| t.require_mention), }); if let Err(e) = save_creds(&app, &creds) { return json_500(e); @@ -652,6 +729,8 @@ pub(crate) async fn handle_put_lark( app_secret: app_secret.to_string(), allowed_user_ids: form.allowed_user_ids, use_feishu: form.use_feishu, + // Re-saving the app must not reset the group-reply switch. + require_mention: creds.lark.as_ref().is_some_and(|l| l.require_mention), }); if let Err(e) = save_creds(&app, &creds) { return json_500(e); @@ -724,7 +803,10 @@ pub(crate) async fn handle_put_slack( Ok(c) => c, Err(e) => return json_500(e), }; - creds.slack = Some(result.creds); + let mut slack = result.creds; + // Re-saving the tokens must not reset the group-reply switch. + slack.require_mention = creds.slack.as_ref().is_some_and(|s| s.require_mention); + creds.slack = Some(slack); if let Err(e) = save_creds(&app, &creds) { return json_500(e); } @@ -767,7 +849,9 @@ pub struct SlackManifestResponse { /// `GET /api/v1/config/im/slack/app-manifest` — the manifest for a new Slack /// app and a one-click link that creates it, so setup never starts from a /// hand-copied YAML. The manifest's one home is -/// [`ccteam_im::onboarding::slack_app_manifest`]. +/// [`ccteam_im::onboarding::slack_app_manifest`]. Open to every signed-in +/// identity: it holds no secret, and a regular user creates their OWN Slack +/// app from it exactly as the owner creates the global one. #[utoipa::path( get, path = "/api/v1/config/im/slack/app-manifest", @@ -775,16 +859,11 @@ pub struct SlackManifestResponse { params(SlackManifestQuery), responses( (status = 200, description = "Manifest + create-app link", body = SlackManifestResponse), - (status = 403, description = "Not an admin"), ), )] pub(crate) async fn handle_get_slack_manifest( - Extension(identity): Extension, axum::extract::Query(query): axum::extract::Query, ) -> Response { - if let Some(deny) = deny_non_admin(&identity) { - return deny; - } let name = query.name.unwrap_or_default(); Json(SlackManifestResponse { manifest: ccteam_im::onboarding::slack_app_manifest(&name), @@ -885,6 +964,89 @@ pub(crate) async fn handle_put_slack_allowed_users( .into_response() } +// -------------------------------------------------------------------------- +// PUT /config/im/{platform}/require-mention — the group-reply switch +// -------------------------------------------------------------------------- + +/// The IMs that carry the `require_mention` switch — ONE list for the owner's +/// and the tenants' endpoints, so a new IM is one entry and a typo is one +/// `400`, never a silent no-op. +pub(crate) const REQUIRE_MENTION_PLATFORMS: [&str; 3] = ["telegram", "lark", "slack"]; + +/// `PUT /config/im/{platform}/require-mention` body (and the tenant twin). +#[derive(Debug, Deserialize, ToSchema)] +pub struct RequireMentionForm { + /// `true` = in a group chat / channel the bot answers only messages that + /// @-mention it (DMs always; on Slack also a thread it already holds a + /// session in). `false` (the default) = it answers every message from an + /// allowed sender. + pub require_mention: bool, +} + +/// The unknown-platform `400` shared by both require-mention endpoints. +pub(crate) fn unknown_platform_400(platform: &str) -> Response { + json_400(format!( + "unknown IM platform `{platform}` (expected one of: {})", + REQUIRE_MENTION_PLATFORMS.join(", ") + )) +} + +/// `PUT /api/v1/config/im/{platform}/require-mention` — flip the group-reply +/// switch of one configured IM bot. Takes the flag alone: the tokens are +/// write-only, so toggling a reply rule must not mean re-entering them. +#[utoipa::path( + put, + path = "/api/v1/config/im/{platform}/require-mention", + tag = "config", + params(("platform" = String, Path, description = "telegram | lark | slack")), + request_body(content = RequireMentionForm, description = "The desired switch value"), + responses( + (status = 200, description = "Saved; `{ok, platform, require_mention, reloaded, restart_required, note}`", body = serde_json::Value), + (status = 400, description = "Unknown platform / that IM is not configured yet"), + (status = 403, description = "Not an admin"), + (status = 500, description = "Credentials file read/write failed"), + ), +)] +pub(crate) async fn handle_put_require_mention( + State(app): State, + Extension(identity): Extension, + axum::extract::Path(platform): axum::extract::Path, + Json(form): Json, +) -> Response { + if let Some(deny) = deny_non_admin(&identity) { + return deny; + } + let mut creds = match load_creds(&app) { + Ok(c) => c, + Err(e) => return json_500(e), + }; + let slot = match platform.as_str() { + "telegram" => creds.telegram.as_mut().map(|c| &mut c.require_mention), + "lark" => creds.lark.as_mut().map(|c| &mut c.require_mention), + "slack" => creds.slack.as_mut().map(|c| &mut c.require_mention), + _ => return unknown_platform_400(&platform), + }; + let Some(slot) = slot else { + return json_400(format!( + "no {platform} bot configured; save its credentials first" + )); + }; + *slot = form.require_mention; + if let Err(e) = save_creds(&app, &creds) { + return json_500(e); + } + let reloaded = nudge_im_reload(&app).await; + Json(serde_json::json!({ + "ok": true, + "platform": platform, + "require_mention": form.require_mention, + "reloaded": reloaded, + "restart_required": !reloaded, + "note": reload_note(reloaded), + })) + .into_response() +} + // -------------------------------------------------------------------------- // Test-only seam: drive the async poll against a mock base // -------------------------------------------------------------------------- @@ -930,6 +1092,8 @@ mod tests { configured: true, bot_token_last4: "…wxyz".into(), chat_id_count: 1, + allowed_chat_ids: vec!["42".into()], + require_mention: false, }; let v = serde_json::to_value(&s).unwrap(); assert!(v.get("bot_token").is_none(), "no bot_token key"); @@ -943,10 +1107,12 @@ mod tests { bot_token_last4: "…abcd".into(), app_token_last4: "…wxyz".into(), allowed_user_ids: vec!["U1".into()], + require_mention: true, }; let v = serde_json::to_value(&s).unwrap(); assert!(v.get("bot_token").is_none(), "no bot_token key"); assert!(v.get("app_token").is_none(), "no app_token key"); assert_eq!(v["allowed_user_ids"], serde_json::json!(["U1"])); + assert_eq!(v["require_mention"], true); } } diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index aa9e0ac2..eb604f83 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -187,7 +187,11 @@ fn build_api_v1() -> OpenApiRouter { // v0.8.20 F3 — admin re-reveals a tenant's personal login link. .routes(routes!(super::users::handle_user_link)) // v0.8.20 F2 — per-user IM bot config (self-serve `/me/im` + admin). - .routes(routes!(super::users::handle_put_me_im)) + .routes(routes!( + super::users::handle_put_me_im, + super::users::handle_get_me_im + )) + .routes(routes!(super::users::handle_put_me_require_mention)) .routes(routes!( super::users::handle_get_me_lark_open_id_candidates, super::users::handle_put_me_lark_allowed_users @@ -200,6 +204,10 @@ fn build_api_v1() -> OpenApiRouter { .routes(routes!( super::users::handle_put_me_telegram_allowed_chats )) + .routes(routes!( + super::users::handle_get_me_slack_user_id_candidates + )) + .routes(routes!(super::users::handle_put_me_slack_allowed_users)) .routes(routes!(super::users::handle_put_user_im)) // v0.8.9 Phase 4 — daemon-wide status aggregate (cost pill + Status view) .routes(routes!(super::status::handle_status)) @@ -298,6 +306,7 @@ fn build_api_v1() -> OpenApiRouter { .routes(routes!(super::im_config::handle_get_slack_manifest)) .routes(routes!(super::im_config::handle_get_slack_user_id_candidates)) .routes(routes!(super::im_config::handle_put_slack_allowed_users)) + .routes(routes!(super::im_config::handle_put_require_mention)) // v0.8.9 Phase 2 — ccteam-hub plugin marketplace: global catalog + // body preview, plus per-project decorated catalog + install. .routes(routes!(super::marketplace::handle_marketplace)) diff --git a/crates/ccteam-web/src/routes/users.rs b/crates/ccteam-web/src/routes/users.rs index 6ab79015..c4686c4d 100644 --- a/crates/ccteam-web/src/routes/users.rs +++ b/crates/ccteam-web/src/routes/users.rs @@ -20,7 +20,7 @@ use axum::{ response::{IntoResponse, Response}, Extension, Json, }; -use ccteam_core::tenants::{Tenant, TenantLark, TenantRegistry, TenantTelegram}; +use ccteam_core::tenants::{Tenant, TenantLark, TenantRegistry, TenantSlack, TenantTelegram}; use serde::{Deserialize, Serialize}; use serde_json::json; use utoipa::ToSchema; @@ -257,6 +257,26 @@ pub struct PutTenantImForm { #[serde(default, deserialize_with = "explicit_option")] #[schema(value_type = Option)] pub lark: Option>, + /// The tenant's own Slack app. Absent → unchanged; `null` → no Slack bot. + /// Both tokens are checked against Slack before anything is written. + #[serde(default, deserialize_with = "explicit_option")] + #[schema(value_type = Option)] + pub slack: Option>, +} + +/// Slack app tokens in a [`PutTenantImForm`]. +#[derive(Debug, Deserialize, ToSchema)] +pub struct SlackImForm { + /// `xoxb-…` bot token (validated via `auth.test`). + pub bot_token: String, + /// `xapp-…` app-level token, `connections:write` (validated via + /// `apps.connections.open`). + pub app_token: String, + /// Slack member ids (`U…`) allowed to drive this tenant's bot. Absent + /// keeps the ids already bound (a member id names the PERSON, not the + /// app, so a token change must not unbind them). + #[serde(default)] + pub allowed_user_ids: Option>, } /// Deserialize a field so an ABSENT key and an explicit `null` stay @@ -335,6 +355,8 @@ async fn apply_tenant_im(app: &AppState, tenant_id: &str, form: PutTenantImForm) .as_ref() .map(|t| t.allowed_chat_ids.clone()) .unwrap_or_default(), + // A token change must not reset the group-reply switch. + require_mention: current.telegram.as_ref().is_some_and(|t| t.require_mention), }), Err(err) => { return ( @@ -354,10 +376,54 @@ async fn apply_tenant_im(app: &AppState, tenant_id: &str, form: PutTenantImForm) app_secret: l.app_secret, allowed_user_ids: normalize_lark_user_ids(l.allowed_user_ids), use_feishu: l.use_feishu, + require_mention: current.lark.as_ref().is_some_and(|c| c.require_mention), }), }; + let slack = match form.slack { + None => current.slack.clone(), + Some(None) => None, + Some(Some(s)) => { + let allowed = s + .allowed_user_ids + .map(normalize_lark_user_ids) + .unwrap_or_else(|| { + current + .slack + .as_ref() + .map(|c| c.allowed_user_ids.clone()) + .unwrap_or_default() + }); + match ccteam_im::onboarding::slack_setup_with_base( + s.bot_token.trim(), + s.app_token.trim(), + allowed, + &super::im_config::slack_api_base(), + ) + .await + { + Ok(result) => Some(TenantSlack { + bot_token: result.creds.bot_token, + app_token: result.creds.app_token, + allowed_user_ids: result.creds.allowed_user_ids, + require_mention: current.slack.as_ref().is_some_and(|c| c.require_mention), + }), + Err(err) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("Slack credentials rejected: {err}")})), + ) + .into_response() + } + } + } + }; let has_telegram = telegram.is_some(); let has_lark = lark.is_some(); + let has_slack = slack.is_some(); + // Fail-closed like the others: say so instead of letting it read as silence. + let slack_unbound = slack + .as_ref() + .is_some_and(|s| s.allowed_user_ids.is_empty()); // An unbound Telegram bot answers nobody (fail-closed, like Lark) — say so // in the response instead of letting the tenant discover it as silence. let telegram_unbound = telegram @@ -366,6 +432,7 @@ async fn apply_tenant_im(app: &AppState, tenant_id: &str, form: PutTenantImForm) reg.set_telegram(tenant_id, telegram); reg.set_lark(tenant_id, lark); + reg.set_slack(tenant_id, slack); if let Err(err) = reg.save_one(&path, tenant_id) { tracing::error!(%err, "PUT tenant im: registry save failed"); return ( @@ -387,7 +454,9 @@ async fn apply_tenant_im(app: &AppState, tenant_id: &str, form: PutTenantImForm) "ok": true, "telegram": has_telegram, "lark": has_lark, + "slack": has_slack, "telegram_unbound": telegram_unbound, + "slack_unbound": slack_unbound, "reloaded": reloaded, "note": if reloaded { "saved; your bot listener is (re)starting now" @@ -428,6 +497,144 @@ pub(crate) async fn handle_put_me_im( apply_tenant_im(&app, &identity.id, form).await } +/// `GET /api/v1/me/im` — the caller's OWN per-user IM bots, masked (never a +/// token), in the same shape as the owner's `GET /config/im`: which bots exist, +/// who they answer, and whether each one needs an @-mention in a group. +#[utoipa::path( + get, + path = "/api/v1/me/im", + tag = "users", + responses( + (status = 200, description = "Masked per-user IM status", body = super::im_config::ImConfigStatus), + (status = 400, description = "Admin caller (uses /config/im)"), + (status = 404, description = "Caller is not a registered tenant"), + ), +)] +pub(crate) async fn handle_get_me_im( + State(app): State, + Extension(identity): Extension, +) -> Response { + if identity.is_admin { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "the owner's bot is the global bot — read it via /api/v1/config/im"})), + ) + .into_response(); + } + let reg = TenantRegistry::load(&app.paths.users_dir()); + let Some(tenant) = reg.by_id(&identity.id) else { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("unknown tenant: {}", identity.id)})), + ) + .into_response(); + }; + use super::im_config::{im_status, lark_status, slack_status, telegram_status}; + Json(im_status( + tenant + .telegram + .as_ref() + .map(|t| telegram_status(&t.bot_token, t.allowed_chat_ids.clone(), t.require_mention)), + tenant.lark.as_ref().map(|l| { + lark_status( + &l.app_id, + l.use_feishu, + l.allowed_user_ids.clone(), + l.require_mention, + ) + }), + tenant.slack.as_ref().map(|s| { + slack_status( + &s.bot_token, + &s.app_token, + s.allowed_user_ids.clone(), + s.require_mention, + ) + }), + )) + .into_response() +} + +/// `PUT /api/v1/me/im/{platform}/require-mention` — flip the group-reply +/// switch of the caller's OWN bot on one IM, without re-entering its tokens. +#[utoipa::path( + put, + path = "/api/v1/me/im/{platform}/require-mention", + tag = "users", + params(("platform" = String, Path, description = "telegram | lark | slack")), + request_body(content = super::im_config::RequireMentionForm, description = "The desired switch value"), + responses( + (status = 200, description = "Saved; `{ok, platform, require_mention, reloaded, note}`", body = serde_json::Value), + (status = 400, description = "Admin caller / unknown platform / that bot is not configured yet"), + (status = 404, description = "Caller is not a registered tenant"), + (status = 500, description = "Registry write failed"), + ), +)] +pub(crate) async fn handle_put_me_require_mention( + State(app): State, + Extension(identity): Extension, + Path(platform): Path, + Json(form): Json, +) -> Response { + if identity.is_admin { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "the owner's bot is the global bot — set it via /api/v1/config/im"})), + ) + .into_response(); + } + let path = app.paths.users_dir(); + let mut reg = TenantRegistry::load(&path); + let Some(mut tenant) = reg.by_id(&identity.id).cloned() else { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("unknown tenant: {}", identity.id)})), + ) + .into_response(); + }; + let slot = match platform.as_str() { + "telegram" => tenant.telegram.as_mut().map(|c| &mut c.require_mention), + "lark" => tenant.lark.as_mut().map(|c| &mut c.require_mention), + "slack" => tenant.slack.as_mut().map(|c| &mut c.require_mention), + _ => return super::im_config::unknown_platform_400(&platform), + }; + let Some(slot) = slot else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("no {platform} bot configured; save its credentials first")})), + ) + .into_response(); + }; + *slot = form.require_mention; + reg.set_telegram(&identity.id, tenant.telegram); + reg.set_lark(&identity.id, tenant.lark); + reg.set_slack(&identity.id, tenant.slack); + if let Err(err) = reg.save_one(&path, &identity.id) { + tracing::error!(%err, "PUT /api/v1/me/im/{{platform}}/require-mention: registry save failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{err}")})), + ) + .into_response(); + } + let reloaded = match app.gateway.as_ref() { + Some(gw) => gw.lock().await.request_im_reload(), + None => false, + }; + Json(json!({ + "ok": true, + "platform": platform, + "require_mention": form.require_mention, + "reloaded": reloaded, + "note": if reloaded { + "saved; your bot listener is (re)starting now" + } else { + "saved; takes effect on the next daemon reload/restart" + }, + })) + .into_response() +} + #[derive(Debug, Deserialize, ToSchema)] pub struct LarkAllowedUsersForm { #[serde(default)] @@ -632,6 +839,109 @@ pub(crate) async fn handle_put_me_lark_allowed_users( .into_response() } +/// `GET /api/v1/me/im/slack/user-id-candidates` — tenants poll this while +/// binding their own Slack app: it lists the member ids THEIR bot +/// (`slack@`) saw and rejected. Those messages reached no agent. +#[utoipa::path( + get, + path = "/api/v1/me/im/slack/user-id-candidates", + tag = "users", + params(("since" = Option, Query, description = "Only candidates at/after this Unix timestamp")), + responses( + (status = 200, description = "Recent rejected Slack members for this tenant", body = SenderCandidatesResponse), + (status = 400, description = "Admin caller (uses global config)"), + ), +)] +pub(crate) async fn handle_get_me_slack_user_id_candidates( + State(app): State, + Extension(identity): Extension, + Query(query): Query, +) -> Response { + if identity.is_admin { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "the owner's bot is the global bot — use /api/v1/config/im/slack/user-id-candidates"})), + ) + .into_response(); + } + let channel = format!("slack@{}", identity.id); + Json(SenderCandidatesResponse { + candidates: read_sender_candidates(&probe_path(&app), &channel, query.since), + }) + .into_response() +} + +/// `PUT /api/v1/me/im/slack/allowed-users` — set the caller's own Slack +/// member allowlist without re-entering the tokens (they are never echoed). +#[utoipa::path( + put, + path = "/api/v1/me/im/slack/allowed-users", + tag = "users", + request_body(content = LarkAllowedUsersForm, description = "Full desired allowed_user_ids list"), + responses( + (status = 200, description = "Allowlist updated; `{ok, slack, reloaded, note}`", body = serde_json::Value), + (status = 400, description = "Admin caller / no Slack app configured"), + (status = 404, description = "Caller is not a registered tenant"), + (status = 500, description = "Registry write failed"), + ), +)] +pub(crate) async fn handle_put_me_slack_allowed_users( + State(app): State, + Extension(identity): Extension, + Json(form): Json, +) -> Response { + if identity.is_admin { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "the owner's bot is the global bot — set it via /api/v1/config/im"})), + ) + .into_response(); + } + let path = app.paths.users_dir(); + let mut reg = TenantRegistry::load(&path); + let Some(tenant) = reg.by_id(&identity.id).cloned() else { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": format!("unknown tenant: {}", identity.id)})), + ) + .into_response(); + }; + let Some(mut slack) = tenant.slack else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "no Slack app configured; save the bot and app-level tokens first"})), + ) + .into_response(); + }; + slack.allowed_user_ids = normalize_lark_user_ids(form.allowed_user_ids); + let allow_count = slack.allowed_user_ids.len(); + reg.set_slack(&identity.id, Some(slack)); + if let Err(err) = reg.save_one(&path, &identity.id) { + tracing::error!(%err, "PUT /api/v1/me/im/slack/allowed-users: registry save failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{err}")})), + ) + .into_response(); + } + let reloaded = match app.gateway.as_ref() { + Some(gw) => gw.lock().await.request_im_reload(), + None => false, + }; + Json(json!({ + "ok": true, + "slack": true, + "allowed_user_id_count": allow_count, + "reloaded": reloaded, + "note": if reloaded { + "saved; your Slack bot listener is (re)starting now" + } else { + "saved; takes effect on the next daemon reload/restart" + }, + })) + .into_response() +} + /// `GET /api/v1/me/im/telegram/chat-id-candidates` — tenants poll this while /// binding their own Telegram bot. A per-tenant bot is fail-closed, so until a /// chat is allowlisted every inbound message is dropped and recorded here; the diff --git a/crates/ccteam-web/tests/im_config_test.rs b/crates/ccteam-web/tests/im_config_test.rs index 49c13f6d..ac1b8f3e 100644 --- a/crates/ccteam-web/tests/im_config_test.rs +++ b/crates/ccteam-web/tests/im_config_test.rs @@ -183,17 +183,20 @@ async fn get_im_config_masks_secrets() { telegram: Some(TelegramCreds { bot_token: "111222:SUPERSECRETTOKENvalue".into(), allowed_chat_ids: vec!["98765".into()], + require_mention: false, }), lark: Some(LarkCreds { app_id: "cli_app_xyz".into(), app_secret: "larkAPPSECRETvalue".into(), allowed_user_ids: vec!["ou_a".into(), "ou_b".into()], use_feishu: true, + require_mention: false, }), slack: Some(SlackCreds { bot_token: "xoxb-SLACKBOTSECRETvalue".into(), app_token: "xapp-SLACKAPPSECRETtail".into(), allowed_user_ids: vec!["U0ALICE".into()], + require_mention: false, }), ..Default::default() }; @@ -319,6 +322,7 @@ async fn put_telegram_preserves_existing_chat_ids() { telegram: Some(TelegramCreds { bot_token: "old".into(), allowed_chat_ids: vec!["42".into()], + require_mention: false, }), ..Default::default() }, @@ -478,6 +482,7 @@ async fn put_slack_valid_tokens_persist_and_preserve_other_platforms() { telegram: Some(TelegramCreds { bot_token: "tg".into(), allowed_chat_ids: vec!["42".into()], + require_mention: false, }), ..Default::default() }, @@ -576,6 +581,7 @@ async fn chat_id_capture_writes_into_allowlist() { telegram: Some(TelegramCreds { bot_token: "111:TOK".into(), allowed_chat_ids: vec![], + require_mention: false, }), ..Default::default() }, @@ -719,6 +725,7 @@ async fn slack_allowed_users_update_keeps_the_tokens() { bot_token: "xoxb-keep".into(), app_token: "xapp-keep".into(), allowed_user_ids: vec![], + require_mention: false, }), ..Default::default() }, @@ -790,6 +797,574 @@ async fn slack_user_id_candidates_are_the_global_bots_rejected_senders() { ); } +// -------------------------------------------------------------------------- +// A regular user's OWN Slack app — symmetric with their Telegram / Lark bot +// -------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn a_tenant_sets_up_their_own_slack_app() { + use ccteam_core::tenants::TenantRegistry; + let base = spawn_slack_mock(true, true).await; + std::env::set_var("CCTEAM_SLACK_API_BASE", &base); + + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + std::fs::create_dir_all(&paths.root).unwrap(); + let mut reg = TenantRegistry::default(); + let alice = reg.add("alice"); + let bob = reg.add("bob"); + reg.save(&paths.users_dir()).unwrap(); + let users = paths.users_dir(); + let probe = paths.im_state_dir().join("rejected-senders.jsonl"); + let (state, _) = state_with_creds(&tmp, AuthState::enabled(TOKEN_HEX.into())); + let addr = spawn_app(state).await; + let alice_auth = format!("Bearer ccteam:{}", alice.web_token); + + // ② Tokens, validated against Slack; fail-closed until someone is allowed. + let v: Value = client() + .put(format!("http://{addr}/api/v1/me/im")) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({"slack": {"bot_token": "xoxb-a", "app_token": "xapp-a"}})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(v["slack"], true, "{v}"); + assert_eq!(v["slack_unbound"], true, "{v}"); + let saved = TenantRegistry::load(&users) + .by_id(&alice.id) + .unwrap() + .clone(); + assert_eq!(saved.slack.as_ref().unwrap().bot_token, "xoxb-a"); + assert!(saved.lark.is_none() && saved.telegram.is_none()); + + // ③ Capture is scoped to HER bot (`slack@`), then one click allows. + std::fs::create_dir_all(probe.parent().unwrap()).unwrap(); + let row = |channel: String, sender: &str| { + serde_json::json!({ + "channel": channel, "sender_id": sender, "chat_id": "D1", + "message_id": "1.1", "timestamp": 2000_u64, + }) + .to_string() + }; + std::fs::write( + &probe, + [ + row(format!("slack@{}", alice.id), "U0ALICE"), + row(format!("slack@{}", bob.id), "U0BOB"), + row("slack".into(), "U0OWNER"), + ] + .join("\n"), + ) + .unwrap(); + let candidates: Value = client() + .get(format!( + "http://{addr}/api/v1/me/im/slack/user-id-candidates" + )) + .header("Authorization", &alice_auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let ids: Vec<&str> = candidates["candidates"] + .as_array() + .unwrap() + .iter() + .map(|c| c["sender_id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["U0ALICE"], "only her own bot's rejected senders"); + let r = client() + .put(format!("http://{addr}/api/v1/me/im/slack/allowed-users")) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({"allowed_user_ids": ["U0ALICE"]})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + + // A later token change keeps the binding (a member id names the person). + client() + .put(format!("http://{addr}/api/v1/me/im")) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({"slack": {"bot_token": "xoxb-a2", "app_token": "xapp-a2"}})) + .send() + .await + .unwrap(); + let saved = TenantRegistry::load(&users) + .by_id(&alice.id) + .unwrap() + .clone(); + let slack = saved.slack.unwrap(); + assert_eq!(slack.bot_token, "xoxb-a2"); + assert_eq!(slack.allowed_user_ids, vec!["U0ALICE"]); + assert!( + TenantRegistry::load(&users) + .by_id(&bob.id) + .unwrap() + .slack + .is_none(), + "bob is untouched" + ); + + std::env::remove_var("CCTEAM_SLACK_API_BASE"); +} + +// -------------------------------------------------------------------------- +// `require_mention` — the group-reply switch, the same on every IM +// -------------------------------------------------------------------------- + +/// Creds with all three IMs configured and the switch off everywhere. +fn all_three_ims() -> Credentials { + Credentials { + telegram: Some(TelegramCreds { + bot_token: "111:tg-secret".into(), + allowed_chat_ids: vec!["42".into()], + require_mention: false, + }), + lark: Some(LarkCreds { + app_id: "cli_x".into(), + app_secret: "lark-secret".into(), + allowed_user_ids: vec!["ou_a".into()], + use_feishu: true, + require_mention: false, + }), + slack: Some(SlackCreds { + bot_token: "xoxb-keep".into(), + app_token: "xapp-keep".into(), + allowed_user_ids: vec!["U0ALICE".into()], + require_mention: false, + }), + ..Default::default() + } +} + +fn switch_flags(c: &Credentials) -> [bool; 3] { + [ + c.telegram.as_ref().unwrap().require_mention, + c.lark.as_ref().unwrap().require_mention, + c.slack.as_ref().unwrap().require_mention, + ] +} + +/// The owner flips each IM's switch with one flag — no tokens re-entered, no +/// other IM touched — and the masked read reports it back, per IM. +#[tokio::test] +async fn require_mention_switch_is_symmetric_across_ims_and_keeps_the_tokens() { + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + credentials::save(&creds_path, &all_three_ims()).unwrap(); + let addr = spawn_app(state).await; + + for (i, platform) in ["telegram", "lark", "slack"].into_iter().enumerate() { + let url = format!("http://{addr}/api/v1/config/im/{platform}/require-mention"); + let r = client() + .put(&url) + .json(&serde_json::json!({"require_mention": true})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "{platform}"); + let v: Value = r.json().await.unwrap(); + assert_eq!(v["platform"], platform); + assert_eq!(v["require_mention"], true); + assert_eq!( + v["restart_required"], true, + "standalone web: no live reload" + ); + + let mut want = [false; 3]; + for flag in want.iter_mut().take(i + 1) { + *flag = true; + } + let saved = credentials::load(Some(&creds_path)).unwrap(); + assert_eq!(switch_flags(&saved), want, "after {platform}"); + assert_eq!(saved.slack.as_ref().unwrap().bot_token, "xoxb-keep"); + assert_eq!(saved.lark.as_ref().unwrap().app_secret, "lark-secret"); + assert_eq!(saved.telegram.as_ref().unwrap().bot_token, "111:tg-secret"); + } + + // The masked read carries it for each IM. + let status: Value = client() + .get(format!("http://{addr}/api/v1/config/im")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + for platform in ["telegram", "lark", "slack"] { + assert_eq!(status[platform]["require_mention"], true, "{platform}"); + } + + // And back off. + let r = client() + .put(format!( + "http://{addr}/api/v1/config/im/slack/require-mention" + )) + .json(&serde_json::json!({"require_mention": false})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); + let saved = credentials::load(Some(&creds_path)).unwrap(); + assert_eq!(switch_flags(&saved), [true, true, false]); +} + +#[tokio::test] +async fn require_mention_switch_refuses_unknown_and_unconfigured_ims() { + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + let addr = spawn_app(state).await; + let put = |platform: &str| { + client() + .put(format!( + "http://{addr}/api/v1/config/im/{platform}/require-mention" + )) + .json(&serde_json::json!({"require_mention": true})) + .send() + }; + + // Nothing configured yet: every IM says so, nothing is written. + for platform in ["telegram", "lark", "slack"] { + assert_eq!(put(platform).await.unwrap().status(), 400, "{platform}"); + } + // A typo is a 400, never a silent no-op. + let r = put("slakc").await.unwrap(); + assert_eq!(r.status(), 400); + assert!(r.text().await.unwrap().contains("telegram, lark, slack")); + assert!( + !creds_path.exists() + || credentials::load(Some(&creds_path)) + .unwrap() + .slack + .is_none(), + "a refused flip writes nothing" + ); +} + +/// The switch is the owner's: a tenant is refused, and the endpoint sits +/// behind the web-token gate like the rest of `/config/im`. +#[tokio::test] +async fn require_mention_switch_is_admin_only_behind_the_gate() { + use ccteam_core::tenants::TenantRegistry; + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + std::fs::create_dir_all(&paths.root).unwrap(); + let mut reg = TenantRegistry::default(); + let alice = reg.add("alice"); + reg.save(&paths.users_dir()).unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::enabled(TOKEN_HEX.into())); + credentials::save(&creds_path, &all_three_ims()).unwrap(); + let addr = spawn_app(state).await; + let url = format!("http://{addr}/api/v1/config/im/slack/require-mention"); + let body = serde_json::json!({"require_mention": true}); + + let r = client().put(&url).json(&body).send().await.unwrap(); + assert_eq!(r.status(), 401); + let r = client() + .put(&url) + .header( + "Authorization", + format!("Bearer ccteam:{}", alice.web_token), + ) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 403, "a tenant cannot flip the owner's bot"); + assert!( + !credentials::load(Some(&creds_path)) + .unwrap() + .slack + .unwrap() + .require_mention + ); + let r = client() + .put(&url) + .header("Authorization", format!("Bearer ccteam:{TOKEN_HEX}")) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200); +} + +/// Saving the tokens again (a rotated bot token, a re-run setup) is not a +/// reason to forget how the bot was told to behave in groups. +#[tokio::test] +#[serial] +async fn re_saving_an_ims_credentials_keeps_its_require_mention_switch() { + let tg_base = spawn_telegram_mock(true, "ccteam_bot", 1).await; + let lark_base = spawn_lark_mock(0).await; + let slack_base = spawn_slack_mock(true, true).await; + std::env::set_var("CCTEAM_TELEGRAM_API_BASE", &tg_base); + std::env::set_var("CCTEAM_LARK_API_BASE", &lark_base); + std::env::set_var("CCTEAM_SLACK_API_BASE", &slack_base); + + let tmp = TempDir::new().unwrap(); + let (state, creds_path) = state_with_creds(&tmp, AuthState::disabled()); + let mut creds = all_three_ims(); + creds.telegram.as_mut().unwrap().require_mention = true; + creds.lark.as_mut().unwrap().require_mention = true; + creds.slack.as_mut().unwrap().require_mention = true; + credentials::save(&creds_path, &creds).unwrap(); + let addr = spawn_app(state).await; + + for (platform, body) in [ + ("telegram", serde_json::json!({"bot_token": "222:rotated"})), + ( + "lark", + serde_json::json!({"app_id": "cli_y", "app_secret": "s2", "use_feishu": true, + "allowed_user_ids": ["ou_a"]}), + ), + ( + "slack", + serde_json::json!({"bot_token": "xoxb-new", "app_token": "xapp-new"}), + ), + ] { + let r = client() + .put(format!("http://{addr}/api/v1/config/im/{platform}")) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "{platform}: {:?}", r.text().await); + } + let saved = credentials::load(Some(&creds_path)).unwrap(); + assert_eq!(saved.telegram.as_ref().unwrap().bot_token, "222:rotated"); + assert_eq!(saved.lark.as_ref().unwrap().app_id, "cli_y"); + assert_eq!(saved.slack.as_ref().unwrap().bot_token, "xoxb-new"); + assert_eq!( + switch_flags(&saved), + [true, true, true], + "a token change must not reset the switch" + ); + + for var in [ + "CCTEAM_TELEGRAM_API_BASE", + "CCTEAM_LARK_API_BASE", + "CCTEAM_SLACK_API_BASE", + ] { + std::env::remove_var(var); + } +} + +/// A regular user reads and flips the switch of their OWN bots, on every IM, +/// through the same shapes the owner has; nobody else's bot moves, and a +/// token change keeps it. +#[tokio::test] +#[serial] +async fn a_tenant_reads_and_flips_the_require_mention_switch_of_their_own_bots() { + use ccteam_core::tenants::{TenantLark, TenantRegistry, TenantSlack, TenantTelegram}; + let tg_base = spawn_telegram_mock(true, "alice_bot", 1).await; + let slack_base = spawn_slack_mock(true, true).await; + std::env::set_var("CCTEAM_TELEGRAM_API_BASE", &tg_base); + std::env::set_var("CCTEAM_SLACK_API_BASE", &slack_base); + + let tmp = TempDir::new().unwrap(); + let paths = fake_paths(tmp.path()); + std::fs::create_dir_all(&paths.root).unwrap(); + let users = paths.users_dir(); + let mut reg = TenantRegistry::default(); + let alice = reg.add("alice"); + let bob = reg.add("bob"); + reg.set_telegram( + &alice.id, + Some(TenantTelegram { + bot_token: "111:alice-secret".into(), + allowed_chat_ids: vec!["42".into()], + require_mention: false, + }), + ); + reg.set_lark( + &alice.id, + Some(TenantLark { + app_id: "cli_alice".into(), + app_secret: "alice-lark-secret".into(), + allowed_user_ids: vec!["ou_1".into(), "ou_2".into()], + use_feishu: true, + require_mention: false, + }), + ); + reg.set_slack( + &alice.id, + Some(TenantSlack { + bot_token: "xoxb-alice".into(), + app_token: "xapp-alice".into(), + allowed_user_ids: vec!["U0ALICE".into()], + require_mention: false, + }), + ); + reg.set_slack( + &bob.id, + Some(TenantSlack { + bot_token: "xoxb-bob".into(), + app_token: "xapp-bob".into(), + allowed_user_ids: vec![], + require_mention: false, + }), + ); + reg.save(&users).unwrap(); + let (state, _) = state_with_creds(&tmp, AuthState::enabled(TOKEN_HEX.into())); + let addr = spawn_app(state).await; + let alice_auth = format!("Bearer ccteam:{}", alice.web_token); + let admin_auth = format!("Bearer ccteam:{TOKEN_HEX}"); + + // She can read her own bots — masked, same shape as the owner's read. + let me: Value = client() + .get(format!("http://{addr}/api/v1/me/im")) + .header("Authorization", &alice_auth) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(me["telegram"]["chat_id_count"], 1, "{me}"); + assert_eq!(me["lark"]["allowed_user_id_count"], 2, "{me}"); + // The saved allowlists come back too: the allowlist PUTs replace the whole + // list, so the card has to start from what is already bound. + assert_eq!( + me["telegram"]["allowed_chat_ids"], + serde_json::json!(["42"]) + ); + assert_eq!( + me["lark"]["allowed_user_ids"], + serde_json::json!(["ou_1", "ou_2"]) + ); + assert_eq!( + me["slack"]["allowed_user_ids"], + serde_json::json!(["U0ALICE"]) + ); + for platform in ["telegram", "lark", "slack"] { + assert_eq!(me[platform]["configured"], true); + assert_eq!(me[platform]["require_mention"], false, "{platform}"); + } + let body = me.to_string(); + for secret in [ + "alice-secret", + "alice-lark-secret", + "xoxb-alice", + "xapp-alice", + ] { + assert!(!body.contains(secret), "no secret in the read: {secret}"); + } + + // Flip each of her bots. + for platform in ["telegram", "lark", "slack"] { + let r = client() + .put(format!( + "http://{addr}/api/v1/me/im/{platform}/require-mention" + )) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({"require_mention": true})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "{platform}"); + let v: Value = r.json().await.unwrap(); + assert_eq!(v["platform"], platform); + assert_eq!(v["require_mention"], true); + } + let reg = TenantRegistry::load(&users); + let a = reg.by_id(&alice.id).unwrap(); + assert!(a.telegram.as_ref().unwrap().require_mention); + assert!(a.lark.as_ref().unwrap().require_mention); + assert!(a.slack.as_ref().unwrap().require_mention); + assert_eq!(a.slack.as_ref().unwrap().bot_token, "xoxb-alice"); + assert_eq!( + a.lark.as_ref().unwrap().allowed_user_ids, + vec!["ou_1", "ou_2"] + ); + assert!( + !reg.by_id(&bob.id) + .unwrap() + .slack + .as_ref() + .unwrap() + .require_mention, + "bob's bot did not move" + ); + + // A token change keeps it (Telegram + Slack validate against the mocks). + let r = client() + .put(format!("http://{addr}/api/v1/me/im")) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({ + "telegram_bot_token": "222:rotated", + "lark": {"app_id": "cli_alice2", "app_secret": "s2"}, + "slack": {"bot_token": "xoxb-a2", "app_token": "xapp-a2"}, + })) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 200, "{:?}", r.text().await); + let reg = TenantRegistry::load(&users); + let a = reg.by_id(&alice.id).unwrap(); + assert_eq!(a.telegram.as_ref().unwrap().bot_token, "222:rotated"); + assert_eq!(a.lark.as_ref().unwrap().app_id, "cli_alice2"); + assert_eq!(a.slack.as_ref().unwrap().bot_token, "xoxb-a2"); + assert!( + a.telegram.as_ref().unwrap().require_mention + && a.lark.as_ref().unwrap().require_mention + && a.slack.as_ref().unwrap().require_mention, + "a token change must not reset the switch" + ); + + // Bob has no Telegram bot: refused, not silently created. A typo too. + let bob_auth = format!("Bearer ccteam:{}", bob.web_token); + let r = client() + .put(format!( + "http://{addr}/api/v1/me/im/telegram/require-mention" + )) + .header("Authorization", &bob_auth) + .json(&serde_json::json!({"require_mention": true})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 400); + let r = client() + .put(format!( + "http://{addr}/api/v1/me/im/discord/require-mention" + )) + .header("Authorization", &alice_auth) + .json(&serde_json::json!({"require_mention": true})) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 400); + + // The owner's bot is the global one: the tenant endpoints say so. + for method_url in [ + ("GET", format!("http://{addr}/api/v1/me/im")), + ( + "PUT", + format!("http://{addr}/api/v1/me/im/slack/require-mention"), + ), + ] { + let req = match method_url.0 { + "GET" => client().get(&method_url.1), + _ => client() + .put(&method_url.1) + .json(&serde_json::json!({"require_mention": true})), + }; + let r = req + .header("Authorization", &admin_auth) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 400, "{}", method_url.1); + } + + std::env::remove_var("CCTEAM_TELEGRAM_API_BASE"); + std::env::remove_var("CCTEAM_SLACK_API_BASE"); +} + // -------------------------------------------------------------------------- // Web-token gate // -------------------------------------------------------------------------- diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 7ff9fe48..eeb1f358 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -86,6 +86,10 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { ("GET", "/api/v1/users/{id}/link"), // v0.8.20 F2 — per-user IM bot config (self-serve + admin). ("PUT", "/api/v1/me/im"), + // The caller's own bots, masked (same shape as GET /config/im). + ("GET", "/api/v1/me/im"), + // The group-reply switch, one route for every IM (owner + tenant). + ("PUT", "/api/v1/me/im/{platform}/require-mention"), ("GET", "/api/v1/me/im/lark/open-id-candidates"), ("PUT", "/api/v1/me/im/lark/allowed-users"), // Telegram twin — a per-tenant bot is fail-closed, so the tenant needs @@ -171,6 +175,9 @@ fn expected_operations() -> BTreeSet<(&'static str, &'static str)> { ("GET", "/api/v1/config/im/slack/app-manifest"), ("GET", "/api/v1/config/im/slack/user-id-candidates"), ("PUT", "/api/v1/config/im/slack/allowed-users"), + ("PUT", "/api/v1/config/im/{platform}/require-mention"), + ("GET", "/api/v1/me/im/slack/user-id-candidates"), + ("PUT", "/api/v1/me/im/slack/allowed-users"), // v0.8.9 Phase 2 — ccteam-hub plugin marketplace. ("GET", "/api/v1/marketplace"), ("GET", "/api/v1/marketplace/{id}/body"), diff --git a/crates/ccteam-web/tests/tenant_acl_test.rs b/crates/ccteam-web/tests/tenant_acl_test.rs index f93b421f..c0d97950 100644 --- a/crates/ccteam-web/tests/tenant_acl_test.rs +++ b/crates/ccteam-web/tests/tenant_acl_test.rs @@ -79,8 +79,22 @@ async fn tenant_token_keeps_only_user_management_and_global_im_admin_only() { "tenant must be 403 on the global Slack config" ); // …and the Slack setup helpers (manifest link, sender capture, allowlist). + // The create-app manifest holds no secret: a regular user builds their + // OWN Slack app from it, exactly as the owner does. + let r = c + .get(format!( + "http://{addr}/api/v1/config/im/slack/app-manifest?name=mine" + )) + .header("Authorization", format!("Bearer ccteam:{tenant_tok}")) + .send() + .await + .unwrap(); + assert_eq!( + r.status(), + 200, + "every signed-in user may build a Slack app" + ); for (method, path) in [ - ("GET", "/api/v1/config/im/slack/app-manifest"), ("GET", "/api/v1/config/im/slack/user-id-candidates"), ("PUT", "/api/v1/config/im/slack/allowed-users"), ] { @@ -818,6 +832,7 @@ async fn tenant_telegram_binds_its_own_chat_and_starts_unbound() { Some(ccteam_core::tenants::TenantTelegram { bot_token: "111:AAA".into(), allowed_chat_ids: Vec::new(), + require_mention: false, }), ); reg.save(&paths.users_dir()).unwrap(); diff --git a/crates/ccteam-web/web/src/lib/configApi.test.ts b/crates/ccteam-web/web/src/lib/configApi.test.ts index ce3b7e41..ae83e7ba 100644 --- a/crates/ccteam-web/web/src/lib/configApi.test.ts +++ b/crates/ccteam-web/web/src/lib/configApi.test.ts @@ -16,6 +16,7 @@ import { saveSlack, getSlackAppManifest, getSlackUserIdCandidates, + putRequireMention, putSlackAllowedUsers, saveTelegramToken, startTelegramChatId, @@ -41,18 +42,27 @@ describe("configApi", () => { it("getImConfig GETs /config/im with same-origin creds and returns masked status", async () => { const masked = { - telegram: { configured: true, bot_token_last4: "…wxyz", chat_id_count: 1 }, + telegram: { + configured: true, + bot_token_last4: "…wxyz", + chat_id_count: 1, + allowed_chat_ids: ["42"], + require_mention: false, + }, lark: { configured: true, app_id_last4: "…cli9", use_feishu: true, allowed_user_id_count: 2, + allowed_user_ids: ["ou_1", "ou_2"], + require_mention: true, }, slack: { configured: true, bot_token_last4: "…bot1", app_token_last4: "…app1", allowed_user_ids: ["U0ALICE"], + require_mention: false, }, transport_warning: "no TLS", }; @@ -73,12 +83,16 @@ describe("configApi", () => { "configured", "bot_token_last4", "chat_id_count", + "allowed_chat_ids", + "require_mention", ]); expect(Object.keys(got.lark ?? {})).toEqual([ "configured", "app_id_last4", "use_feishu", "allowed_user_id_count", + "allowed_user_ids", + "require_mention", ]); expect(got.slack).not.toHaveProperty("bot_token"); expect(got.slack).not.toHaveProperty("app_token"); @@ -87,9 +101,38 @@ describe("configApi", () => { "bot_token_last4", "app_token_last4", "allowed_user_ids", + "require_mention", ]); }); + it("putRequireMention PUTs {require_mention} to /config/im/{platform}/require-mention", async () => { + const fetchMock = vi.mocked(globalThis.fetch); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + ok: true, + platform: "lark", + require_mention: true, + reloaded: true, + restart_required: false, + note: "applied", + }), + ); + const got = await putRequireMention("lark", true); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/config/im/lark/require-mention"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ require_mention: true }); + expect(got.require_mention).toBe(true); + expect(got.note).toBe("applied"); + }); + + it("putRequireMention surfaces the server {error} on 400 (IM not configured)", async () => { + vi.mocked(globalThis.fetch).mockResolvedValueOnce( + jsonResponse(400, { error: "Slack is not configured" }), + ); + await expect(putRequireMention("slack", true)).rejects.toThrow("Slack is not configured"); + }); + it("getImConfig tolerates null provider blocks", async () => { vi.mocked(globalThis.fetch).mockResolvedValueOnce( jsonResponse(200, { telegram: null, lark: null, slack: null, transport_warning: "" }), diff --git a/crates/ccteam-web/web/src/lib/configApi.ts b/crates/ccteam-web/web/src/lib/configApi.ts index f3356b0d..531d131a 100644 --- a/crates/ccteam-web/web/src/lib/configApi.ts +++ b/crates/ccteam-web/web/src/lib/configApi.ts @@ -24,6 +24,9 @@ import type { SenderCandidatesResponse } from "./usersApi"; +/** The three IMs a per-platform setting is keyed by (the URL segment). */ +export type ImPlatform = "telegram" | "lark" | "slack"; + /** Masked Telegram status (`im_config::TelegramStatus`). No token field. */ export interface TelegramStatus { /** Always `true` when present (a block exists on disk). */ @@ -32,6 +35,12 @@ export interface TelegramStatus { bot_token_last4: string; /** How many `chat_id`s are bound (the allowlist length). */ chat_id_count: number; + /** The bound `chat_id`s (not secrets) — allowlist PUTs replace the whole + * list, so an editor starts from these. */ + allowed_chat_ids: string[]; + /** Groups/channels: `true` = only answer messages that @-mention the bot; + * `false` (default) = answer every allowed member. DMs always answer. */ + require_mention: boolean; } /** Masked Lark/Feishu status (`im_config::LarkStatus`). No app_secret field. */ @@ -44,6 +53,10 @@ export interface LarkStatus { use_feishu: boolean; /** How many `open_id`s are allowlisted. */ allowed_user_id_count: number; + /** The allowlisted `open_id`s (not secrets). */ + allowed_user_ids: string[]; + /** Same @-mention gate as Telegram (`TelegramStatus.require_mention`). */ + require_mention: boolean; } /** Masked Slack status (`im_config::SlackStatus`). No token fields — only @@ -57,6 +70,9 @@ export interface SlackStatus { app_token_last4: string; /** Slack member ids (`U…`) allowed to drive the bot — empty = no one. */ allowed_user_ids: string[]; + /** Same @-mention gate as Telegram; a thread the bot already holds a + * session in continues without the @. */ + require_mention: boolean; } /** `GET /api/v1/config/im` response — masked, secret-free @@ -297,3 +313,28 @@ export function putSlackAllowedUsers(ids: string[]): Promise { + return putJson( + `/api/v1/config/im/${platform}/require-mention`, + { require_mention }, + ); +} diff --git a/crates/ccteam-web/web/src/lib/usersApi.test.ts b/crates/ccteam-web/web/src/lib/usersApi.test.ts index 7e1bf3f9..d6964c4a 100644 --- a/crates/ccteam-web/web/src/lib/usersApi.test.ts +++ b/crates/ccteam-web/web/src/lib/usersApi.test.ts @@ -11,6 +11,10 @@ import { listUsers, putMyIm, putMyLarkAllowedUsers, + getMySlackUserIdCandidates, + putMySlackAllowedUsers, + getMyIm, + putMyRequireMention, } from "./usersApi"; const realFetch = globalThis.fetch; @@ -198,3 +202,83 @@ describe("per-user Telegram binding (the fail-closed bot's way in)", () => { ); }); }); + +describe("per-user Slack binding (symmetric with Telegram / Lark)", () => { + it("getMySlackUserIdCandidates GETs the caller's own discovery endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { candidates: [] })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + await getMySlackUserIdCandidates(1500); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/me/im/slack/user-id-candidates?since=1500"); + globalThis.fetch = realFetch; + }); + + it("putMySlackAllowedUsers PUTs only the member ids", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(200, { ok: true, allowed_user_id_count: 1 })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + await putMySlackAllowedUsers(["U0ALICE"]); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/me/im/slack/allowed-users"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ allowed_user_ids: ["U0ALICE"] }); + globalThis.fetch = realFetch; + }); +}); + +describe("per-user require-@-mention (symmetric with the admin's /config/im)", () => { + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("getMyIm GETs the caller's own masked status in the admin's shape", async () => { + const mine = { + telegram: { + configured: true, + bot_token_last4: "…wxyz", + chat_id_count: 1, + allowed_chat_ids: ["42"], + require_mention: true, + }, + lark: null, + slack: null, + transport_warning: "", + }; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, mine)); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const got = await getMyIm(); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/me/im", { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + expect(got).toEqual(mine); + // Secret-free, like the admin's read (red line). + expect(got.telegram).not.toHaveProperty("bot_token"); + }); + + it("putMyRequireMention PUTs {require_mention} to the caller's own platform route", async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse(200, { + ok: true, + platform: "telegram", + require_mention: false, + reloaded: true, + note: "applied", + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const got = await putMyRequireMention("telegram", false); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/me/im/telegram/require-mention"); + expect(init.method).toBe("PUT"); + expect(JSON.parse(init.body as string)).toEqual({ require_mention: false }); + expect(got.require_mention).toBe(false); + }); + + it("putMyRequireMention surfaces the 400 reason when that bot isn't configured", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse(400, { error: "no Lark bot configured" })) as unknown as typeof fetch; + await expect(putMyRequireMention("lark", true)).rejects.toThrow("no Lark bot configured"); + }); +}); diff --git a/crates/ccteam-web/web/src/lib/usersApi.ts b/crates/ccteam-web/web/src/lib/usersApi.ts index e23c3ffe..cd8ff97b 100644 --- a/crates/ccteam-web/web/src/lib/usersApi.ts +++ b/crates/ccteam-web/web/src/lib/usersApi.ts @@ -8,6 +8,7 @@ // 403 → throw Error("FORBIDDEN") (caller is a tenant, not the admin) // other non-2xx → throw Error("HTTP ") +import type { ImConfigStatus, ImPlatform } from "./configApi"; import { httpError } from "./httpError"; /** One tenant as `GET /api/v1/users` returns it — never carries the token. */ @@ -73,6 +74,14 @@ export interface PutMyImForm { allowed_user_ids?: string[]; use_feishu?: boolean; } | null; + /** Omit → unchanged; `null` → no Slack bot. Both tokens are checked with + * Slack before anything is saved; omitting `allowed_user_ids` keeps the + * members already bound. */ + slack?: { + bot_token: string; + app_token: string; + allowed_user_ids?: string[]; + } | null; } /** `PUT /api/v1/me/im` — the caller sets its OWN per-user IM bot (self-serve). @@ -90,6 +99,10 @@ export interface PutMyImResult { /** A Telegram bot is configured but has an empty chat allowlist → it will * answer no one until `putMyTelegramAllowedChats` binds a chat. */ telegram_unbound: boolean; + slack?: boolean; + /** A Slack app is configured but allows no member yet → it answers no one + * until `putMySlackAllowedUsers` binds one. */ + slack_unbound?: boolean; reloaded?: boolean; note?: string; } @@ -157,6 +170,52 @@ export function putMyLarkAllowedUsers( ); } +/** Poll the Slack member ids the caller's OWN Slack app rejected (not + * allowed yet) — DM the bot and your own `U…` shows up here. */ +export function getMySlackUserIdCandidates( + since?: number, +): Promise { + const qs = since ? `?since=${encodeURIComponent(String(since))}` : ""; + return getJson( + `/api/v1/me/im/slack/user-id-candidates${qs}`, + ); +} + +/** Set the caller's own Slack member allowlist without re-entering tokens. */ +export function putMySlackAllowedUsers( + allowed_user_ids: string[], +): Promise<{ ok: boolean; allowed_user_id_count: number; note?: string }> { + return sendJson<{ ok: boolean; allowed_user_id_count: number; note?: string }>( + "/api/v1/me/im/slack/allowed-users", + "PUT", + { allowed_user_ids }, + ); +} + +/** `GET /api/v1/me/im` — the caller's OWN bots, masked exactly like the + * admin's `getImConfig` (same shape; a platform is `null` until the tenant + * configured it; never a secret). The admin gets 400 — the owner's bots + * live under `/config/im`. */ +export function getMyIm(): Promise { + return getJson("/api/v1/me/im"); +} + +/** `PUT /api/v1/me/im/{platform}/require-mention` — flip only the caller's + * own @-mention gate for groups/channels (tokens and allowlists untouched). + * 400 when that bot isn't configured yet. */ +export function putMyRequireMention( + platform: ImPlatform, + require_mention: boolean, +): Promise<{ + ok: boolean; + platform: ImPlatform; + require_mention: boolean; + reloaded: boolean; + note?: string; +}> { + return sendJson(`/api/v1/me/im/${platform}/require-mention`, "PUT", { require_mention }); +} + async function getJson(url: string): Promise { let res: Response; try { diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx index 8b50b7ae..432c0905 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx @@ -1,18 +1,25 @@ +// @vitest-environment jsdom // v0.8.8 F4 — Settings section smoke tests. // // AccessView owns the admin's masked IM-status fetch and composes the named // Telegram/Lark sections. User management stays on the 管理员 · Admin tab. // -// No DOM env (no jsdom): use React's `renderToString` to assert each named -// section's initial HTML shape, mirroring SessionsListPage.test.tsx. We assert: +// Shape tests use React's `renderToString` to assert each named section's +// initial HTML, mirroring SessionsListPage.test.tsx. We assert: // - configured sections default to compact masked summaries // - unconfigured sections default to empty forms, and that the // masked status NEVER echoes a plaintext secret (red-line guard) // Interactive paths (token save → chat_id poll loop, overwrite confirm) are -// covered by configApi.test.ts + manual / Playwright host E2E. +// covered by configApi.test.ts + manual / Playwright host E2E. The one +// interactive surface tested here is the require-@-mention switch (mounted in +// jsdom, fetch mocked): it is the same row on six cards against two APIs. -import { describe, expect, it } from "vitest"; +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ImConfigStatus } from "../lib/configApi"; +import { toastBus } from "../lib/toastBus"; import { LarkSection, MyImSection, @@ -25,7 +32,13 @@ describe("Settings sections", () => { it("TelegramSection (configured) defaults to its compact masked summary", () => { const html = renderToString( {}} />, ); @@ -37,6 +50,13 @@ describe("Settings sections", () => { // Collapsed means no secret field exists until the operator explicitly edits. expect(html).not.toContain('type="password"'); expect(html).not.toContain('value="…wxyz"'); + // A configured bot carries the group @-mention switch, off by default, + // with the generic hint (the thread clause is Slack's alone). + expect(html).toContain('data-testid="settings-telegram-require-mention"'); + expect(html).toContain('role="switch"'); + expect(html).toContain('aria-checked="false"'); + expect(html).toContain("群聊/频道里需要 @ 机器人才回复"); + expect(html).not.toContain("线程里可免 @"); }); it("TelegramSection (unconfigured) shows the not-configured state", () => { @@ -52,6 +72,8 @@ describe("Settings sections", () => { expect(html).toContain('data-testid="settings-telegram-token"'); expect(html).toContain('type="password"'); expect(html).toContain('value=""'); + // Nothing to gate until a bot exists. + expect(html).not.toContain('data-testid="settings-telegram-require-mention"'); }); it("LarkSection (configured) renders its testid + masked app id + region", () => { @@ -62,6 +84,8 @@ describe("Settings sections", () => { app_id_last4: "…cli9", use_feishu: true, allowed_user_id_count: 2, + allowed_user_ids: ["ou_1", "ou_2"], + require_mention: true, }} onSaved={() => {}} />, @@ -71,6 +95,9 @@ describe("Settings sections", () => { expect(html).toContain("…cli9"); expect(html).toContain("Feishu (CN)"); expect(html).not.toContain('type="password"'); + // The switch reflects the server value (here: already requiring @). + expect(html).toContain('data-testid="settings-lark-require-mention"'); + expect(html).toContain('aria-checked="true"'); }); it("LarkSection (unconfigured) uses the compact two-column form and region segment", () => { @@ -95,6 +122,7 @@ describe("Settings sections", () => { bot_token_last4: "…bot1", app_token_last4: "…app1", allowed_user_ids: ["U0ALICE", "U0BOB"], + require_mention: false, }} onSaved={() => {}} />, @@ -107,6 +135,9 @@ describe("Settings sections", () => { expect(html).toContain('data-testid="settings-slack-remove-U0ALICE"'); expect(html).toContain('data-testid="settings-slack-remove-U0BOB"'); expect(html).not.toContain('type="password"'); + // The @-mention switch sits in step ③ with Slack's extra thread clause. + expect(html).toContain('data-testid="settings-slack-require-mention"'); + expect(html).toContain("机器人已有会话的线程里可免 @ 继续对话"); }); it("SlackSection (tokens saved, nobody allowed) asks for step 3 with sender capture", () => { @@ -117,6 +148,7 @@ describe("Settings sections", () => { bot_token_last4: "…bot1", app_token_last4: "…app1", allowed_user_ids: [], + require_mention: false, }} onSaved={() => {}} />, @@ -147,6 +179,24 @@ describe("Settings sections", () => { // ③ binding waits for saved tokens expect(html).toContain('data-testid="settings-slack-step-bind"'); expect(html).not.toContain('data-testid="settings-slack-capture"'); + expect(html).not.toContain('data-testid="settings-slack-require-mention"'); + }); + + it("MyImSection gives Slack the same self-serve card as Telegram and Lark", () => { + const html = renderToString(); + expect(html).toContain('data-testid="my-im-slack"'); + // Same three steps as the owner's admin card: create → tokens → allow. + expect(html).toContain('data-testid="my-im-slack-create-link"'); + expect(html).toContain('id="my-im-slack-bot-token"'); + expect(html).toContain('id="my-im-slack-app-token"'); + expect(html).toContain('data-testid="my-im-slack-save"'); + expect(html).toContain('data-testid="my-im-slack-capture"'); + expect(html).toContain('data-testid="my-im-slack-allowlist-save"'); + expect(html).toContain("Telegram / Lark / Slack"); + expect(html).not.toMatch(/id="my-im-slack-(bot|app)-token"[^>]*value="[^"]+"/); + // Before `/me/im` answers (effects don't run in SSR) no card has a bot, + // so no card offers the @-mention switch. + expect(html).not.toContain("require-mention"); }); it("MyImSection guides Telegram and Lark as two separate stepped cards", () => { @@ -178,3 +228,248 @@ describe("Settings sections", () => { expect(html).toContain("用户管理 · Users"); }); }); + +// -------------------------------------------------------------------------- +// Require @-mention in groups/channels — the one row on six cards. Mounted +// for real (jsdom) so a click drives the PUT and the switch follows the +// response, not the click. +// -------------------------------------------------------------------------- + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const telegramStatus = (require_mention: boolean): NonNullable => ({ + configured: true, + bot_token_last4: "…wxyz", + chat_id_count: 1, + allowed_chat_ids: ["42"], + require_mention, +}); +const larkStatus = (require_mention: boolean): NonNullable => ({ + configured: true, + app_id_last4: "…cli9", + use_feishu: true, + allowed_user_id_count: 2, + allowed_user_ids: ["ou_1", "ou_2"], + require_mention, +}); +const slackStatus = (require_mention: boolean): NonNullable => ({ + configured: true, + bot_token_last4: "…bot1", + app_token_last4: "…app1", + allowed_user_ids: ["U0ALICE"], + require_mention, +}); + +/** A fetch that answers any require-mention PUT (echoing the body, or `putStatus` + * with an `{error}`) and the tenant's `GET /me/im` with `mine`. Everything else + * (manifest / candidate polls) hangs — the cards render without them. */ +function fetchFor(mine: ImConfigStatus | null, putStatus = 200) { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (init?.method === "PUT" && url.endsWith("/require-mention")) { + if (putStatus !== 200) { + return Promise.resolve(jsonResponse(putStatus, { error: "not configured" })); + } + const body = JSON.parse(String(init.body)) as { require_mention: boolean }; + return Promise.resolve( + jsonResponse(200, { + ok: true, + platform: url.split("/").at(-2), + require_mention: body.require_mention, + reloaded: true, + restart_required: false, + note: "已生效", + }), + ); + } + if (mine && !init?.method && url.endsWith("/api/v1/me/im")) { + return Promise.resolve(jsonResponse(200, mine)); + } + return new Promise(() => {}); + }); +} + +describe("require @-mention switch", () => { + let container: HTMLDivElement; + let root: ReturnType; + const realFetch = globalThis.fetch; + const toasts: string[] = []; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + toasts.length = 0; + toastBus.handler = { + push: (m) => toasts.push(m), + info: (m) => toasts.push(m), + error: (m) => toasts.push(`error: ${m}`), + }; + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + toastBus.handler = null; + vi.restoreAllMocks(); + }); + + async function mount(ui: ReactNode) { + await act(async () => { + root = createRoot(container); + root.render(ui); + }); + } + const switchOf = (testid: string) => + container.querySelector(`[data-testid="${testid}"]`) as HTMLButtonElement | null; + const putCalls = () => + vi.mocked(globalThis.fetch).mock.calls.filter(([, init]) => init?.method === "PUT"); + + const ownerCard = { + telegram: (on: boolean) => ( + {}} /> + ), + lark: (on: boolean) => {}} />, + slack: (on: boolean) => {}} />, + }; + + it.each(["telegram", "lark", "slack"] as const)( + "owner %s card: the switch mirrors status.require_mention and PUTs the flip to /config/im", + async (platform) => { + globalThis.fetch = fetchFor(null); + await mount(ownerCard[platform](false)); + const sw = switchOf(`settings-${platform}-require-mention`); + expect(sw).not.toBeNull(); + expect(sw!.getAttribute("role")).toBe("switch"); + expect(sw!.getAttribute("aria-checked")).toBe("false"); + // The label is wired to the control, so the text is clickable too. + expect(container.querySelector(`label[for="settings-${platform}-require-mention"]`)).not.toBeNull(); + + await act(async () => sw!.click()); + expect(putCalls()).toHaveLength(1); + const [url, init] = putCalls()[0]; + expect(String(url)).toBe(`/api/v1/config/im/${platform}/require-mention`); + expect(JSON.parse(String(init?.body))).toEqual({ require_mention: true }); + // Follows the response, and the daemon's note is what the operator sees. + expect(sw!.getAttribute("aria-checked")).toBe("true"); + expect(sw!.disabled).toBe(false); + expect(toasts).toEqual(["已生效"]); + }, + ); + + it("owner card already requiring @: renders on, and the flip PUTs false", async () => { + globalThis.fetch = fetchFor(null); + await mount(ownerCard.telegram(true)); + const sw = switchOf("settings-telegram-require-mention")!; + expect(sw.getAttribute("aria-checked")).toBe("true"); + await act(async () => sw.click()); + expect(JSON.parse(String(putCalls()[0][1]?.body))).toEqual({ require_mention: false }); + expect(sw.getAttribute("aria-checked")).toBe("false"); + }); + + it("tenant cards: GET /me/im decides which card has the switch; the flip goes to /me/im", async () => { + globalThis.fetch = fetchFor({ + telegram: telegramStatus(true), + lark: null, + slack: null, + transport_warning: "", + }); + await mount(); + const gets = vi + .mocked(globalThis.fetch) + .mock.calls.filter(([input, init]) => String(input) === "/api/v1/me/im" && !init?.method); + expect(gets).toHaveLength(1); + + // Only the configured bot offers the gate, pre-set from the server. + const sw = switchOf("my-im-telegram-require-mention"); + expect(sw).not.toBeNull(); + expect(sw!.getAttribute("aria-checked")).toBe("true"); + expect(switchOf("my-im-lark-require-mention")).toBeNull(); + expect(switchOf("my-im-slack-require-mention")).toBeNull(); + + await act(async () => sw!.click()); + expect(putCalls()).toHaveLength(1); + const [url, init] = putCalls()[0]; + expect(String(url)).toBe("/api/v1/me/im/telegram/require-mention"); + expect(JSON.parse(String(init?.body))).toEqual({ require_mention: false }); + expect(sw!.getAttribute("aria-checked")).toBe("false"); + expect(toasts).toEqual(["已生效"]); + }); + + it("tenant cards: every configured bot gets its own switch (lark + slack too)", async () => { + globalThis.fetch = fetchFor({ + telegram: null, + lark: larkStatus(false), + slack: slackStatus(true), + transport_warning: "", + }); + await mount(); + expect(switchOf("my-im-telegram-require-mention")).toBeNull(); + expect(switchOf("my-im-lark-require-mention")!.getAttribute("aria-checked")).toBe("false"); + expect(switchOf("my-im-slack-require-mention")!.getAttribute("aria-checked")).toBe("true"); + await act(async () => switchOf("my-im-lark-require-mention")!.click()); + expect(String(putCalls()[0][0])).toBe("/api/v1/me/im/lark/require-mention"); + }); + + it("tenant cards: a reload shows what is saved — fingerprints and the bound allowlists", async () => { + globalThis.fetch = fetchFor({ + telegram: telegramStatus(false), + lark: larkStatus(false), + slack: slackStatus(false), + transport_warning: "", + }); + await mount(); + const text = (id: string) => container.querySelector(`[data-testid="${id}"]`)?.textContent ?? ""; + expect(text("my-im-telegram-saved")).toContain("…wxyz"); + expect(text("my-im-lark-saved")).toContain("…cli9"); + expect(text("my-im-slack-saved")).toContain("…bot1"); + expect(text("my-im-slack-saved")).toContain("…app1"); + // The allowlist editors start from the saved lists (the PUTs replace the + // whole list), and the tokens are still never echoed back. + expect(text("my-im-telegram-bind")).toContain("42"); + const field = (id: string) => container.querySelector(`#${id}`)!.value; + expect(field("my-im-lark-users")).toBe("ou_1\nou_2"); + expect(field("my-im-slack-users")).toBe("U0ALICE"); + expect(field("my-im-slack-bot-token")).toBe(""); + expect(field("my-im-slack-app-token")).toBe(""); + }); + + it("tenant cards: nothing saved → no saved line and empty allowlists", async () => { + globalThis.fetch = fetchFor({ telegram: null, lark: null, slack: null, transport_warning: "" }); + await mount(); + for (const p of ["telegram", "lark", "slack"]) { + expect(container.querySelector(`[data-testid="my-im-${p}-saved"]`)).toBeNull(); + } + expect(container.querySelector("#my-im-slack-users")!.value).toBe(""); + }); + + it("a failed PUT keeps the old value and toasts the reason (owner and tenant alike)", async () => { + globalThis.fetch = fetchFor( + { telegram: null, lark: null, slack: slackStatus(false), transport_warning: "" }, + 400, + ); + await mount( + <> + {}} /> + + , + ); + for (const id of ["settings-slack-require-mention", "my-im-slack-require-mention"]) { + const sw = switchOf(id); + expect(sw).not.toBeNull(); + await act(async () => sw!.click()); + expect(sw!.getAttribute("aria-checked")).toBe("false"); + expect(sw!.disabled).toBe(false); + } + expect(putCalls()).toHaveLength(2); + expect(toasts).toHaveLength(2); + expect(toasts.every((t) => t.startsWith("error: ") && t.includes("not configured"))).toBe(true); + }); +}); diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.tsx index 088d73b2..4974fd20 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.tsx @@ -46,6 +46,7 @@ import { getSlackAppManifest, getSlackUserIdCandidates, pollTelegramChatId, + putRequireMention, putSlackAllowedUsers, saveLark, saveSlack, @@ -53,6 +54,7 @@ import { startTelegramChatId, type ChatIdPollStatus, type ImConfigStatus, + type ImPlatform, type SlackManifestResult, } from "../lib/configApi"; import { copyText } from "../lib/clipboard"; @@ -60,17 +62,22 @@ import { toastBus } from "../lib/toastBus"; import { createUser, deleteUser, + getMyIm, getMyLarkOpenIdCandidates, + getMySlackUserIdCandidates, getMyTelegramChatIdCandidates, getUserLink, listUsers, putMyIm, putMyLarkAllowedUsers, + putMyRequireMention, + putMySlackAllowedUsers, putMyTelegramAllowedChats, type SenderCandidate, type TenantView, } from "../lib/usersApi"; import { makeT, type Lang } from "../lib/i18n"; +import { cn } from "../lib/utils"; import { Badge, Button, @@ -110,6 +117,97 @@ function Readout({ children }: { children: React.ReactNode }) { return
{children}
; } +// -------------------------------------------------------------------------- +// Require @-mention in groups/channels — one row, six cards (the owner's +// three global bots + a tenant's three own bots). Off (default): the bot +// answers every message an allowed member sends in a group/channel it is in; +// on: only messages that @-mention it. DMs always answer; on Slack a thread +// the bot already holds a session in continues without the @. The daemon +// hot-applies the flip; standalone web answers `restart_required` and says so +// in `note`, which we toast like the allowlist savers do. +// -------------------------------------------------------------------------- + +function RequireMentionRow({ + platform, + value, + save, + testid, + className, +}: { + platform: ImPlatform; + /** The server's current value (from the masked status). */ + value: boolean; + /** `putRequireMention` / `putMyRequireMention` bound to `platform`. */ + save: (require_mention: boolean) => Promise<{ require_mention: boolean; note?: string }>; + testid: string; + className?: string; +}) { + // Local copy so a successful PUT shows at once (no round trip); a refetched + // server value still wins — React's adjust-state-on-prop-change pattern, + // not a setState-in-effect. + const [checked, setChecked] = useState(value); + const [seen, setSeen] = useState(value); + if (seen !== value) { + setSeen(value); + setChecked(value); + } + const [pending, setPending] = useState(false); + + async function toggle() { + if (pending) return; + setPending(true); + try { + const res = await save(!checked); + setChecked(res.require_mention); + toastBus.handler?.info( + res.note || + (res.require_mention + ? "已开启:群聊/频道里只回 @ 机器人的消息" + : "已关闭:群聊/频道里每条消息都回"), + ); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "save failed"); + } finally { + setPending(false); + } + } + + return ( +
+
+ +

+ 关闭(默认):允许名单里的人在群聊/频道里发的每条消息都会回复。开启:只回复 @ + 了机器人的消息;私聊不受影响 + {platform === "slack" ? ";机器人已有会话的线程里可免 @ 继续对话" : ""} +

+
+ +
+ ); +} + // -------------------------------------------------------------------------- // Telegram // -------------------------------------------------------------------------- @@ -340,6 +438,16 @@ export function TelegramSection({ )} + {configured && status ? ( + putRequireMention("telegram", v)} + testid="settings-telegram-require-mention" + /> + ) : null} + {configured ? "下次 daemon 重启生效" : "未配置"} @@ -657,6 +765,16 @@ export function LarkSection({ )} + {configured && status ? ( + putRequireMention("lark", v)} + testid="settings-lark-require-mention" + /> + ) : null} + app secret 永不回显;重配显「(set, ····wxyz)」+ 空白框 · 下次重启生效 ); @@ -1037,6 +1155,15 @@ export function SlackSection({ ) : null} + {configured && status ? ( + putRequireMention("slack", v)} + testid="settings-slack-require-mention" + /> + ) : null} @@ -1142,6 +1269,22 @@ function StepHead({ n, title, done }: { n: number; title: string; done?: boolean } export function MyImSection() { + // The tenant's own masked status (`/me/im`): which bots exist + their + // @-mention gate. Loaded once here and refreshed after a card saves a + // credential, so a freshly connected bot shows its row without a reload. + const [mine, setMine] = useState(null); + const reload = useCallback(() => { + getMyIm() + .then(setMine) + .catch((err) => { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "could not load my IM bots"); + }); + }, []); + useEffect(() => { + reload(); + }, [reload]); + return (
@@ -1151,24 +1294,41 @@ export function MyImSection() { 自助

- 配置你自己的 Telegram / Lark 机器人 —— 它只驱动你自己的 session(不共用管理员的全局 + 配置你自己的 Telegram / Lark / Slack 机器人 —— 它只驱动你自己的 session(不共用管理员的全局 bot)。两边各自独立配置,互不影响;都只回被你允许的人 ,绑定前谁也不回。保存后即时生效。

- - + + +
); } -function MyTelegramCard() { +/** What a tenant card gets from `MyImSection`: its own bot's masked status + * (`null` = not configured yet) + a refetch to call once a credential saved. + * The status is the truth for every step's "done" and every allowlist field — + * a reload must show what is saved, and the allowlist PUTs replace the whole + * list, so an editor that starts empty would silently drop who is bound. */ +interface MyImCardProps { + status: S; + onSaved: () => void; +} + +function MyTelegramCard({ status, onSaved }: MyImCardProps) { const [token, setToken] = useState(""); const [pending, setPending] = useState(false); const [tokenSaved, setTokenSaved] = useState(false); const [captureSince, setCaptureSince] = useState(null); const [allowed, setAllowed] = useState([]); + const savedChats = (status?.allowed_chat_ids ?? []).join("\n"); + const [syncedChats, setSyncedChats] = useState(""); + if (savedChats !== syncedChats) { + setSyncedChats(savedChats); + setAllowed(savedChats ? savedChats.split("\n") : []); + } const candidates = useSenderCapture( captureSince, getMyTelegramChatIdCandidates, @@ -1188,6 +1348,7 @@ function MyTelegramCard() { const res = await putMyIm({ telegram_bot_token: tok }); setToken(""); setTokenSaved(true); + onSaved(); if (res.telegram_unbound) { // Fail-closed: an unbound bot answers nobody — walk straight into ②. startCapture(); @@ -1211,6 +1372,7 @@ function MyTelegramCard() { const res = await putMyTelegramAllowedChats(normalized); setAllowed(normalized); setCaptureSince(null); + onSaved(); toastBus.handler?.info(res.note || "chat_id 已保存,bot 现在只回你"); } catch (err) { if (err instanceof Error && err.message === "UNAUTHENTICATED") return; @@ -1229,7 +1391,12 @@ function MyTelegramCard() {
- + + {status ? ( +

+ 已保存 bot token({status.bot_token_last4});重新填写即替换 +

+ ) : null}
等待消息…

) : null} + {status || tokenSaved ? ( + putMyRequireMention("telegram", v)} + testid="my-im-telegram-require-mention" + /> + ) : null}
); } -function MyLarkCard() { +function MyLarkCard({ status, onSaved }: MyImCardProps) { const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [useFeishu, setUseFeishu] = useState(true); @@ -1323,6 +1499,18 @@ function MyLarkCard() { const [credsSaved, setCredsSaved] = useState(false); const [usersRaw, setUsersRaw] = useState(""); const [allowlistSaved, setAllowlistSaved] = useState(false); + const savedUsers = (status?.allowed_user_ids ?? []).join("\n"); + const [syncedUsers, setSyncedUsers] = useState(""); + if (savedUsers !== syncedUsers) { + setSyncedUsers(savedUsers); + setUsersRaw(savedUsers); + } + const savedFeishu = status?.use_feishu; + const [syncedFeishu, setSyncedFeishu] = useState(undefined); + if (savedFeishu !== syncedFeishu) { + setSyncedFeishu(savedFeishu); + if (savedFeishu !== undefined) setUseFeishu(savedFeishu); + } const [captureSince, setCaptureSince] = useState(null); const candidates = useSenderCapture( captureSince, @@ -1348,6 +1536,7 @@ function MyLarkCard() { setAppId(""); setAppSecret(""); setCredsSaved(true); + onSaved(); // Fail-closed: an empty allowlist answers nobody — walk straight into ②. if (userIds.length === 0) startCapture(); toastBus.handler?.info("Lark 凭据已保存 —— 现在完成第 2 步,允许你自己的 open_id"); @@ -1368,6 +1557,7 @@ function MyLarkCard() { setUsersRaw(normalized.join("\n")); setAllowlistSaved(true); setCaptureSince(null); + onSaved(); toastBus.handler?.info(res.note || "open_id 已保存到 allowlist"); } catch (err) { if (err instanceof Error && err.message === "UNAUTHENTICATED") return; @@ -1387,7 +1577,13 @@ function MyLarkCard() { - + + {status ? ( +

+ 已保存 App({status.app_id_last4} · {status.use_feishu ? "飞书" : "Lark intl"} + );重新填写即替换 +

+ ) : null}
@@ -1443,7 +1639,11 @@ function MyLarkCard() { data-testid="my-im-lark-bind" >
- + 0} + /> {captureSince === null ? (
+ {status || credsSaved ? ( + putMyRequireMention("lark", v)} + testid="my-im-lark-require-mention" + /> + ) : null} +
+
+ + ); +} + +/** A regular user's OWN Slack app — the same three steps the owner's admin + * card walks (create from a link → tokens → allow yourself), against the + * caller's own `/me/im` endpoints. */ +function MySlackCard({ status, onSaved }: MyImCardProps) { + const [appName, setAppName] = useState("ccteam"); + const [manifest, setManifest] = useState(null); + const [botToken, setBotToken] = useState(""); + const [appToken, setAppToken] = useState(""); + const [pending, setPending] = useState(false); + const [tokensSaved, setTokensSaved] = useState(false); + const [allowlistSaved, setAllowlistSaved] = useState(false); + const [usersRaw, setUsersRaw] = useState(""); + const savedUsers = (status?.allowed_user_ids ?? []).join("\n"); + const [syncedUsers, setSyncedUsers] = useState(""); + if (savedUsers !== syncedUsers) { + setSyncedUsers(savedUsers); + setUsersRaw(savedUsers); + } + const [captureSince, setCaptureSince] = useState(null); + const candidates = useSenderCapture( + captureSince, + getMySlackUserIdCandidates, + "Slack member id capture failed", + ); + const userIds = parseUserIds(usersRaw); + + useEffect(() => { + let cancelled = false; + const timer = setTimeout(() => { + getSlackAppManifest(appName.trim()) + .then((res) => { + if (!cancelled) setManifest(res); + }) + .catch(() => { + if (!cancelled) setManifest(null); + }); + }, 250); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [appName]); + + function startCapture() { + setCaptureSince(Math.floor(Date.now() / 1000) - 2); + } + + async function saveTokens(e: React.FormEvent) { + e.preventDefault(); + const bot = botToken.trim(); + const app = appToken.trim(); + if (pending || !bot || !app) return; + setPending(true); + try { + const res = await putMyIm({ slack: { bot_token: bot, app_token: app } }); + setBotToken(""); + setAppToken(""); + setTokensSaved(true); + onSaved(); + // Fail-closed: an app that allows nobody answers nobody — walk into ③. + if (res.slack_unbound) startCapture(); + toastBus.handler?.info( + res.slack_unbound + ? "Slack 已连接 —— 现在私聊 bot 完成第 3 步,允许你自己的成员 ID" + : res.note || "Slack 已连接", + ); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "save failed"); + } finally { + setPending(false); + } + } + + async function saveAllowlist(ids: string[]) { + if (pending) return; + const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).sort(); + setPending(true); + try { + const res = await putMySlackAllowedUsers(normalized); + setUsersRaw(normalized.join("\n")); + setAllowlistSaved(true); + setCaptureSince(null); + onSaved(); + toastBus.handler?.info(res.note || "成员 ID 已保存"); + } catch (err) { + if (err instanceof Error && err.message === "UNAUTHENTICATED") return; + toastBus.handler?.error(err instanceof Error ? err.message : "save failed"); + } finally { + setPending(false); + } + } + + return ( + +
+
+ +

Slack

+ 可选 +
+ +
+ +
+
+ + setAppName(e.target.value)} + spellCheck={false} + placeholder="ccteam" + className="font-mono" + /> +
+ +
+

+ 斜杠命令随 App 名:{slashCommandOf(manifest)} + 。打开后选工作区 → Create → 左侧 Install App → Install to Workspace → Allow。 +

+
+ + + + {status ? ( +

+ 已保存 bot token({status.bot_token_last4})· app token({status.app_token_last4} + );重新填写即替换 +

+ ) : null} +
+
+ + setBotToken(e.target.value)} + disabled={pending} + spellCheck={false} + placeholder="xoxb-…" + className="font-mono" + /> +

OAuth & Permissions → Bot User OAuth Token

+
+
+ + setAppToken(e.target.value)} + disabled={pending} + spellCheck={false} + placeholder="xapp-…" + className="font-mono" + /> +

+ Basic Information → App-Level Tokens → Generate,scope 选 connections:write +

+
+
+
+ +
+ + +
+
+ 0} + /> + {captureSince === null ? ( + + ) : null} +
+

+ 空 allowlist = fail-closed:bot 谁也不回。私聊 bot(或在频道里 @ 它)即可发现你的成员 ID。 +

+ {candidates.length > 0 ? ( +
+ {candidates.map((c) => ( + + ))} +
+ ) : captureSince !== null ? ( +

等待消息…

+ ) : null} +
+
+ +