From c2fda3e42252ca35f3960875508802c8619243b5 Mon Sep 17 00:00:00 2001 From: firstintent <239836187+firstintent@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:10:51 +0800 Subject: [PATCH 01/10] fix(im): a regular user can run their own Slack bot, like Telegram and Lark (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack was configurable only by the owner (the global bot under Settings → Access); a regular user's "My IM bot" section offered Telegram and Lark but no Slack, so the platforms were not symmetric. A tenant now has an optional `TenantSlack` beside `TenantTelegram` / `TenantLark`, and everything the other two get, Slack gets: - the daemon runs a `slack@` channel per configured tenant — fail-closed until a member is allowed, its rejected senders in the shared probe file, and Slack's thread-per-session and button contracts under that name; - `PUT /me/im` takes a `slack` block (both tokens checked with Slack before anything is written; a token change keeps the bound members), plus `GET /me/im/slack/user-id-candidates` (scoped to the caller's own bot) and `PUT /me/im/slack/allowed-users`; - `chat_send_file` resolves a tenant's Slack as a delivery target; - "My IM bot" gets a Slack card with the owner card's three steps. The create-app manifest route is open to every signed-in identity — it holds no secret — while the global bot's capture/allowlist stay owner-only. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com> --- README.md | 2 +- crates/ccteam-core/src/tenants.rs | 27 ++ crates/ccteam-im/src/daemon.rs | 44 +++ crates/ccteam-im/src/mcp/dispatch.rs | 9 + crates/ccteam-web/src/routes/im_config.rs | 11 +- crates/ccteam-web/src/routes/openapi.rs | 4 + crates/ccteam-web/src/routes/users.rs | 170 +++++++++++- crates/ccteam-web/tests/im_config_test.rs | 117 ++++++++ crates/ccteam-web/tests/openapi_test.rs | 2 + crates/ccteam-web/tests/tenant_acl_test.rs | 16 +- .../ccteam-web/web/src/lib/usersApi.test.ts | 25 ++ crates/ccteam-web/web/src/lib/usersApi.ts | 34 +++ .../web/src/pages/SettingsPage.test.tsx | 14 + .../ccteam-web/web/src/pages/SettingsPage.tsx | 250 +++++++++++++++++- docs/usage-cn.md | 2 +- docs/usage.md | 2 +- 16 files changed, 716 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9635a872..f5ca523c 100644 --- a/README.md +++ b/README.md @@ -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-core/src/tenants.rs b/crates/ccteam-core/src/tenants.rs index f6029f02..02dc6c6b 100644 --- a/crates/ccteam-core/src/tenants.rs +++ b/crates/ccteam-core/src/tenants.rs @@ -56,6 +56,18 @@ pub struct TenantLark { pub use_feishu: 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, +} + fn default_true() -> bool { true } @@ -83,6 +95,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 +227,7 @@ impl TenantRegistry { linked_chat: None, telegram: None, lark: None, + slack: None, created_at: Utc::now(), }; self.tenants.push(tenant.clone()); @@ -295,6 +311,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 } diff --git a/crates/ccteam-im/src/daemon.rs b/crates/ccteam-im/src/daemon.rs index 297bb1b6..0437de3d 100644 --- a/crates/ccteam-im/src/daemon.rs +++ b/crates/ccteam-im/src/daemon.rs @@ -1200,6 +1200,25 @@ 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_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 } @@ -2229,6 +2248,31 @@ 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()], + }), + ); + 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. diff --git a/crates/ccteam-im/src/mcp/dispatch.rs b/crates/ccteam-im/src/mcp/dispatch.rs index c1d9e165..28658569 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" diff --git a/crates/ccteam-web/src/routes/im_config.rs b/crates/ccteam-web/src/routes/im_config.rs index 92a55b11..51245ef5 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()) @@ -767,7 +767,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 +777,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), diff --git a/crates/ccteam-web/src/routes/openapi.rs b/crates/ccteam-web/src/routes/openapi.rs index aa9e0ac2..b3df26ec 100644 --- a/crates/ccteam-web/src/routes/openapi.rs +++ b/crates/ccteam-web/src/routes/openapi.rs @@ -200,6 +200,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)) diff --git a/crates/ccteam-web/src/routes/users.rs b/crates/ccteam-web/src/routes/users.rs index 6ab79015..4838ea8a 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 @@ -356,8 +376,50 @@ async fn apply_tenant_im(app: &AppState, tenant_id: &str, form: PutTenantImForm) use_feishu: l.use_feishu, }), }; + 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, + }), + 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 +428,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 +450,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" @@ -632,6 +697,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..d3debc6f 100644 --- a/crates/ccteam-web/tests/im_config_test.rs +++ b/crates/ccteam-web/tests/im_config_test.rs @@ -790,6 +790,123 @@ 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"); +} + // -------------------------------------------------------------------------- // Web-token gate // -------------------------------------------------------------------------- diff --git a/crates/ccteam-web/tests/openapi_test.rs b/crates/ccteam-web/tests/openapi_test.rs index 7ff9fe48..1245f7de 100644 --- a/crates/ccteam-web/tests/openapi_test.rs +++ b/crates/ccteam-web/tests/openapi_test.rs @@ -171,6 +171,8 @@ 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"), + ("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..3527a6e4 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"), ] { diff --git a/crates/ccteam-web/web/src/lib/usersApi.test.ts b/crates/ccteam-web/web/src/lib/usersApi.test.ts index 7e1bf3f9..34603129 100644 --- a/crates/ccteam-web/web/src/lib/usersApi.test.ts +++ b/crates/ccteam-web/web/src/lib/usersApi.test.ts @@ -11,6 +11,8 @@ import { listUsers, putMyIm, putMyLarkAllowedUsers, + getMySlackUserIdCandidates, + putMySlackAllowedUsers, } from "./usersApi"; const realFetch = globalThis.fetch; @@ -198,3 +200,26 @@ 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; + }); +}); diff --git a/crates/ccteam-web/web/src/lib/usersApi.ts b/crates/ccteam-web/web/src/lib/usersApi.ts index e23c3ffe..b536bdc2 100644 --- a/crates/ccteam-web/web/src/lib/usersApi.ts +++ b/crates/ccteam-web/web/src/lib/usersApi.ts @@ -73,6 +73,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 +98,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 +169,28 @@ 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 }, + ); +} + 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..82718db4 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.test.tsx @@ -149,6 +149,20 @@ describe("Settings sections", () => { expect(html).not.toContain('data-testid="settings-slack-capture"'); }); + 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="[^"]+"/); + }); + it("MyImSection guides Telegram and Lark as two separate stepped cards", () => { const html = renderToString(); expect(html).toContain('data-testid="settings-my-im"'); diff --git a/crates/ccteam-web/web/src/pages/SettingsPage.tsx b/crates/ccteam-web/web/src/pages/SettingsPage.tsx index 088d73b2..9089a70c 100644 --- a/crates/ccteam-web/web/src/pages/SettingsPage.tsx +++ b/crates/ccteam-web/web/src/pages/SettingsPage.tsx @@ -61,11 +61,13 @@ import { createUser, deleteUser, getMyLarkOpenIdCandidates, + getMySlackUserIdCandidates, getMyTelegramChatIdCandidates, getUserLink, listUsers, putMyIm, putMyLarkAllowedUsers, + putMySlackAllowedUsers, putMyTelegramAllowedChats, type SenderCandidate, type TenantView, @@ -1151,7 +1153,7 @@ export function MyImSection() { 自助

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

@@ -1159,6 +1161,7 @@ export function MyImSection() { + ); } @@ -1516,6 +1519,251 @@ function MyLarkCard() { ); } +/** 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() { + 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 [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); + // 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); + 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。 +

+
+ +
+ +
+
+ + 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 +

+
+
+
+ +
+ + +
+
+ + {captureSince === null ? ( + + ) : null} +
+

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

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

等待消息…

+ ) : null} +
+
+ +