From e61e09452b292841e98b031d80bb2c5e435579f5 Mon Sep 17 00:00:00 2001 From: "S.L" <101876007+slvnlrt@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:12:44 +0200 Subject: [PATCH 1/8] feat(messaging): add Microsoft Teams channel adapter Add a Microsoft Teams channel adapter built on the Azure Bot Service / Bot Framework Connector protocol. Inbound: an axum webhook server (POST /api/messages) validates the Azure-signed JWT (RS256, JWKS from login.botframework.com, iss/aud/exp), parses Activities into InboundMessage, and enforces per-conversation permissions (DM allowlist with a "*" wildcard; channels open on @mention). Outbound: replies and proactive broadcasts go to the Bot Connector with an Azure AD client-credentials token, guarded by a serviceUrl SSRF allowlist (*.botframework.com / *.trafficmanager.net), with a per-conversation serviceUrl sidecar so proactive sends survive restarts. Capabilities: text and @mentions; Adaptive Cards and card buttons (interactive_elements render as Action.Submit; a click returns as MessageContent::Interaction); a typing indicator; inbound file and image attachments (MessageContent::Media, with the download bearer gated to allowlisted hosts). Config: a [messaging.teams] section (shaped like the other adapters), hot-reloadable permissions via the config watcher, and the client secret sourced from the environment. Setup guide in docs/content/docs/(messaging). Adds one dependency, jsonwebtoken (ring-based). One Teams bot per instance. --- Cargo.lock | 38 + Cargo.toml | 3 + docs/content/docs/(messaging)/messaging.mdx | 3 +- docs/content/docs/(messaging)/meta.json | 2 +- docs/content/docs/(messaging)/teams-setup.mdx | 107 + src/agent/channel.rs | 37 + src/config.rs | 11 +- src/config/load.rs | 249 +- src/config/permissions.rs | 254 +- src/config/toml_schema.rs | 39 + src/config/types.rs | 140 +- src/config/watcher.rs | 51 +- src/conversation/channels.rs | 53 + src/main.rs | 54 + src/messaging.rs | 3 +- src/messaging/target.rs | 208 +- src/messaging/teams.rs | 3074 +++++++++++++++++ src/secrets/store.rs | 28 +- 18 files changed, 4336 insertions(+), 18 deletions(-) create mode 100644 docs/content/docs/(messaging)/teams-setup.mdx create mode 100644 src/messaging/teams.rs diff --git a/Cargo.lock b/Cargo.lock index 66afb6e99..bc81e3480 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4546,6 +4546,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -6391,6 +6406,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -8299,6 +8324,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" @@ -8471,6 +8508,7 @@ dependencies = [ "ignore", "imap", "indoc", + "jsonwebtoken", "lance-index", "lancedb", "lettre", diff --git a/Cargo.toml b/Cargo.toml index f6b6a0195..bc89322b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,9 @@ bollard = "0.18" # Semver parsing (for update version comparison) semver = "1" +# JWT validation for inbound Bot Framework requests (ring-based, no openssl) +jsonwebtoken = "9.3" + # Skill installation zip = "2" tempfile = "3" diff --git a/docs/content/docs/(messaging)/messaging.mdx b/docs/content/docs/(messaging)/messaging.mdx index 2856c4dbe..d71b3a56d 100644 --- a/docs/content/docs/(messaging)/messaging.mdx +++ b/docs/content/docs/(messaging)/messaging.mdx @@ -1,6 +1,6 @@ --- title: Messaging -description: How Spacebot connects to Discord, Slack, Telegram, Twitch, Email, and webhooks. +description: How Spacebot connects to Discord, Slack, Microsoft Teams, Telegram, Twitch, Email, and webhooks. --- # Messaging @@ -13,6 +13,7 @@ Spacebot connects to chat platforms so your agent can talk to people where they |----------|--------|-------------| | [Discord](/docs/discord-setup) | Supported | Bot token + gateway connection | | [Slack](/docs/slack-setup) | Supported | Bot token + app token via Socket Mode | +| [Microsoft Teams](/docs/teams-setup) | Supported | Azure Bot + Entra app, public HTTPS endpoint | | [Telegram](/docs/telegram-setup) | Supported | Bot token via BotFather | | [Twitch](/docs/twitch-setup) | Supported | OAuth token via Twitch IRC | | [Email](/docs/email-setup) | Supported | IMAP polling + SMTP replies | diff --git a/docs/content/docs/(messaging)/meta.json b/docs/content/docs/(messaging)/meta.json index 3e5764b49..a9a7945c1 100644 --- a/docs/content/docs/(messaging)/meta.json +++ b/docs/content/docs/(messaging)/meta.json @@ -1,4 +1,4 @@ { "title": "Messaging", - "pages": ["messaging", "discord-setup", "slack-setup", "telegram-setup", "twitch-setup", "email-setup"] + "pages": ["messaging", "discord-setup", "slack-setup", "teams-setup", "telegram-setup", "twitch-setup", "email-setup"] } diff --git a/docs/content/docs/(messaging)/teams-setup.mdx b/docs/content/docs/(messaging)/teams-setup.mdx new file mode 100644 index 000000000..2c891a5ba --- /dev/null +++ b/docs/content/docs/(messaging)/teams-setup.mdx @@ -0,0 +1,107 @@ +--- +title: Teams Setup +description: Connect Spacebot to Microsoft Teams. +--- + +# Teams Setup + +Connect Spacebot to Microsoft Teams as a bot. Teams talks to your bot over the Azure Bot Service, so this takes a bit more setup than the other channels: an Entra (Azure AD) app registration, an Azure Bot resource, a Teams app package, and a public HTTPS endpoint Microsoft can reach. + +You need three values from Azure — an **App ID**, a **Client Secret**, and a **Tenant ID** — plus a public URL that forwards to Spacebot. + + +Teams requires a **public HTTPS endpoint**. The bot does not connect outbound like Slack; the Azure Bot Service delivers messages by POSTing to a URL you register. A localhost bind is not reachable. Put Spacebot behind a reverse proxy (Caddy, nginx) or a tunnel (cloudflared) that terminates TLS and forwards to the bot's port. + + +## Step 1: Register an Entra app + +In the [Azure portal](https://portal.azure.com), go to **Microsoft Entra ID** → **App registrations** → **New registration**. + +Name it (e.g. "Spacebot"), pick the supported account types for your org, and register. + +From the app's **Overview**, copy the **Application (client) ID** and the **Directory (tenant) ID**. + +Then go to **Certificates & secrets** → **New client secret**, create one, and copy its **Value** immediately (it is shown only once). This is your **Client Secret**. + +## Step 2: Create the Azure Bot + +Create an **Azure Bot** resource (the free **F0** SKU is enough). Point it at the app you just registered (use the existing App ID rather than creating a new identity). + +In the bot's **Configuration**, set the **Messaging endpoint** to your public URL with the `/api/messages` path: + +``` +https://your-public-host/api/messages +``` + +Then open **Channels** and add the **Microsoft Teams** channel. + +## Step 3: Build and install the Teams app + +Teams needs an app package (a zip with a `manifest.json` and two icons) to surface the bot to users. + + +The manifest `id` (the Teams app id) must be its **own** GUID, distinct from the bot's App ID. `bots[].botId` is the App ID from Step 1. Scopes are lowercase (`personal`, `team`, `groupchat`), and `validDomains` plus an `accentColor` are required. + + +Upload the package in Teams (**Apps** → **Manage your apps** → **Upload an app**). Most tenants require admin approval: approve it in the **Teams admin center** under **Manage apps**, where you can also restrict who may install it. + +## Step 4: Configure Spacebot + +Add a `[messaging.teams]` block to your config. Keep the client secret out of the file by referencing an environment variable. + +```toml +[messaging.teams] +enabled = true + +# App ID and Tenant ID from Step 1 (not secret). +app_id = "00000000-0000-0000-0000-000000000000" +tenant_id = "00000000-0000-0000-0000-000000000000" + +# Reference the secret from the environment; never hardcode it. +client_secret = "env:TEAMS_CLIENT_SECRET" + +# Inbound listener. The reverse proxy / tunnel forwards here. +port = 3979 +bind = "0.0.0.0" + +# Teams user IDs allowed to DM the bot. "*" allows everyone. +dm_allowed_users = ["*"] + +[[bindings]] +agent_id = "main" +channel = "teams" +``` + +Start Spacebot with the secret in the environment: + +```bash +TEAMS_CLIENT_SECRET="your-secret-value" spacebot start +``` + +## DM permissions + +Channel and group messages are open: the bot replies wherever it is @mentioned. Direct messages are **fail-closed** — an empty `dm_allowed_users` blocks every DM. + +- To allow specific people, list their Teams user IDs (the `29:...` MRI strings). +- To allow everyone (org-wide deployments), set `dm_allowed_users = ["*"]`. + + +You usually do not know a user's `29:...` MRI up front. The simplest path is to **@mention the bot in a channel** (channel messages are not gated), or set the `"*"` wildcard. To allowlist one person, send a DM once (it is dropped), read the dropped `sender_id` from the logs, and add that value. + + +## What works + +| Capability | Notes | +|------------|-------| +| Text replies | Plain text in DMs, channels, and group chats | +| @mentions | The bot responds when @mentioned in a channel or group | +| Adaptive Cards | Structured cards rendered from rich replies | +| Typing indicator | Shown while the bot is working | +| Inbound files and images | Delivered to the agent (personal scope) | +| Buttons | Adaptive Card buttons; a click comes back to the agent | + +## Limitations + +- **One Teams bot per instance.** A single default `[messaging.teams]` adapter is supported. Named multi-bot instances are not yet available. +- **Files are personal-scope.** Inbound file attachments work in 1:1 chats; channel files require Microsoft Graph and are out of scope. +- **Rotate the client secret** before relying on it in production, and keep it in the environment rather than the config file. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index b256901f0..5dc094f0c 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -3886,6 +3886,13 @@ fn compute_listen_mode_invocation(message: &InboundMessage, raw_text: &str) -> ( .get("twitch_mentions_or_replies_to_bot") .and_then(|v| v.as_bool()) .unwrap_or(false), + "teams" => { + message + .metadata + .get("teams_mentioned") + .and_then(|v| v.as_str()) + == Some("true") + } _ => false, }; let invoked_by_reply = match message.source.as_str() { @@ -4453,6 +4460,36 @@ mod tests { )); } + #[test] + fn teams_mention_metadata_true_string_yields_invoked_by_mention() { + let message = inbound_message( + "teams", + &[("teams_mentioned", serde_json::Value::String("true".into()))], + "hey bot", + ); + + let (invoked_by_command, invoked_by_mention, invoked_by_reply) = + compute_listen_mode_invocation(&message, "hey bot"); + + assert!(!invoked_by_command); + assert!(invoked_by_mention); + assert!(!invoked_by_reply); + } + + #[test] + fn teams_mention_metadata_false_string_does_not_invoke() { + let message = inbound_message( + "teams", + &[("teams_mentioned", serde_json::Value::String("false".into()))], + "hey bot", + ); + + let (_invoked_by_command, invoked_by_mention, _invoked_by_reply) = + compute_listen_mode_invocation(&message, "hey bot"); + + assert!(!invoked_by_mention); + } + #[test] fn is_dm_conversation_id_detects_dm_patterns() { // Slack DMs — channel ID starts with 'D' diff --git a/src/config.rs b/src/config.rs index 7a4c1e3f0..a9c05d1b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,7 @@ pub use load::set_resolve_secrets_store; pub use onboarding::run_onboarding; pub use permissions::{ DiscordPermissions, MattermostPermissions, SignalPermissions, SlackPermissions, - TelegramPermissions, TwitchPermissions, + TeamsPermissions, TelegramPermissions, TwitchPermissions, }; pub(crate) use providers::default_provider_config; pub use runtime::RuntimeConfig; @@ -1637,6 +1637,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![ Binding { @@ -1687,6 +1688,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), @@ -1755,6 +1757,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), @@ -1798,6 +1801,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; // Binding targets default adapter, but no default credentials exist let bindings = vec![Binding { @@ -1842,6 +1846,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![ // Valid: default adapter with credentials @@ -1923,6 +1928,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), @@ -1956,6 +1962,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), @@ -1998,6 +2005,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), @@ -2036,6 +2044,7 @@ id = "main" twitch: None, signal: None, mattermost: None, + teams: None, }; let bindings = vec![Binding { agent_id: "main".into(), diff --git a/src/config/load.rs b/src/config/load.rs index b4c4e5df5..d1d0c4a95 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -17,9 +17,9 @@ use super::{ LinkDef, LlmConfig, MattermostConfig, MattermostInstanceConfig, McpServerConfig, McpTransport, MemoryJanitorConfig, MemoryPersistenceConfig, MessagingConfig, MetricsConfig, OpenCodeConfig, ParticipantContextConfig, ProjectsConfig, ProviderConfig, SignalConfig, SignalInstanceConfig, - SlackCommandConfig, SlackConfig, SlackInstanceConfig, TelegramConfig, TelegramInstanceConfig, - TelemetryConfig, TwitchConfig, TwitchInstanceConfig, WarmupConfig, WebhookConfig, - normalize_adapter, validate_named_messaging_adapters, + SlackCommandConfig, SlackConfig, SlackInstanceConfig, TeamsConfig, TeamsInstanceConfig, + TelegramConfig, TelegramInstanceConfig, TelemetryConfig, TwitchConfig, TwitchInstanceConfig, + WarmupConfig, WebhookConfig, normalize_adapter, validate_named_messaging_adapters, }; use crate::error::{ConfigError, Result}; @@ -2464,6 +2464,62 @@ impl Config { max_attachment_bytes: mm.max_attachment_bytes, }) }), + teams: toml.messaging.teams.and_then(|t| { + let instances = t + .instances + .into_iter() + .map(|instance| { + let app_id = instance.app_id.as_deref().and_then(resolve_env_value); + let client_secret = + instance.client_secret.as_deref().and_then(resolve_env_value); + let tenant_id = instance.tenant_id.as_deref().and_then(resolve_env_value); + let has_credentials = + app_id.is_some() && client_secret.is_some() && tenant_id.is_some(); + if instance.enabled && !has_credentials { + tracing::warn!( + adapter = %instance.name, + "teams instance is enabled but credentials are missing/unresolvable — disabling" + ); + } + TeamsInstanceConfig { + name: instance.name, + enabled: instance.enabled && has_credentials, + app_id: app_id.unwrap_or_default(), + client_secret: client_secret.unwrap_or_default(), + tenant_id: tenant_id.unwrap_or_default(), + dm_allowed_users: instance.dm_allowed_users, + } + }) + .collect::>(); + + let app_id = std::env::var("TEAMS_APP_ID") + .ok() + .or_else(|| t.app_id.as_deref().and_then(resolve_env_value)); + let client_secret = std::env::var("TEAMS_CLIENT_SECRET") + .ok() + .or_else(|| t.client_secret.as_deref().and_then(resolve_env_value)); + let tenant_id = std::env::var("TEAMS_TENANT_ID") + .ok() + .or_else(|| t.tenant_id.as_deref().and_then(resolve_env_value)); + + if (app_id.is_none() || client_secret.is_none() || tenant_id.is_none()) + && instances.is_empty() + { + tracing::warn!("teams config present but no credentials found"); + return None; + } + + Some(TeamsConfig { + enabled: t.enabled, + app_id: app_id.unwrap_or_default(), + client_secret: client_secret.unwrap_or_default(), + tenant_id: tenant_id.unwrap_or_default(), + port: t.port, + bind: t.bind, + instances, + dm_allowed_users: t.dm_allowed_users, + }) + }), }; let bindings: Vec = toml @@ -2683,3 +2739,190 @@ fn load_human_md(human_dir: &std::path::Path) -> Option { _ => None, } } + +#[cfg(test)] +mod teams_config_tests { + use super::super::toml_schema::TomlConfig; + use super::Config; + + fn parse_config(toml: &str) -> Config { + let toml_config: TomlConfig = toml::from_str(toml).expect("TOML parse failed"); + Config::from_toml(toml_config, std::path::PathBuf::from("/tmp/test-spacebot")) + .expect("Config::from_toml failed") + } + + /// Parses a minimal [messaging.teams] block with a default config and one + /// named instance, then asserts the fields are loaded correctly. + #[test] + fn test_teams_config_default_and_named_instance() { + let config = parse_config( + r#" +[messaging.teams] +enabled = true +app_id = "app-id-default" +client_secret = "secret-default" +tenant_id = "tenant-default" +port = 3979 +bind = "0.0.0.0" + +[[messaging.teams.instances]] +name = "acme" +enabled = true +app_id = "app-id-acme" +client_secret = "secret-acme" +tenant_id = "tenant-acme" +"#, + ); + + let teams = config + .messaging + .teams + .as_ref() + .expect("teams config should be present"); + + assert!(teams.enabled); + assert_eq!(teams.app_id, "app-id-default"); + assert_eq!(teams.client_secret, "secret-default"); + assert_eq!(teams.tenant_id, "tenant-default"); + assert_eq!(teams.port, 3979); + assert_eq!(teams.bind, "0.0.0.0"); + assert_eq!(teams.instances.len(), 1); + + let inst = &teams.instances[0]; + assert_eq!(inst.name, "acme"); + assert!(inst.enabled); + assert_eq!(inst.app_id, "app-id-acme"); + assert_eq!(inst.client_secret, "secret-acme"); + assert_eq!(inst.tenant_id, "tenant-acme"); + } + + /// A named Teams instance with a missing client_secret should be disabled + /// with a warning, not cause a hard error. + #[test] + fn test_teams_instance_missing_secret_disables_instance() { + // Default credentials present so the top-level config loads, but + // the named instance has no client_secret. + let config = parse_config( + r#" +[messaging.teams] +enabled = true +app_id = "app-id-default" +client_secret = "secret-default" +tenant_id = "tenant-default" + +[[messaging.teams.instances]] +name = "no-secret" +enabled = true +app_id = "app-id-no-secret" +tenant_id = "tenant-no-secret" +# client_secret intentionally omitted +"#, + ); + + let teams = config + .messaging + .teams + .as_ref() + .expect("default teams config should still be present"); + + // The named instance exists in the list but must be disabled because + // the client_secret was not provided. + let inst = teams + .instances + .iter() + .find(|i| i.name == "no-secret") + .expect("instance should be present even if disabled"); + assert!( + !inst.enabled, + "instance with missing secret must be disabled" + ); + } + + /// When no credentials at all are present, the teams config should be None + /// rather than causing an error. + #[test] + fn test_teams_config_absent_without_credentials() { + let config = parse_config( + r#" +[messaging.teams] +enabled = true +# All credential fields absent +"#, + ); + + assert!( + config.messaging.teams.is_none(), + "teams config without credentials should resolve to None" + ); + } + + /// Verifies that the TOML shape the handler writes round-trips correctly + /// through Config::from_toml for a default Teams instance. + #[test] + fn test_teams_handler_toml_round_trip() { + let config = parse_config( + r#" +[messaging.teams] +enabled = true +app_id = "test-app-id" +client_secret = "test-secret" +tenant_id = "test-tenant" +"#, + ); + + let teams = config + .messaging + .teams + .as_ref() + .expect("teams config should be present"); + + assert_eq!(teams.app_id, "test-app-id"); + assert_eq!(teams.client_secret, "test-secret"); + assert_eq!(teams.tenant_id, "test-tenant"); + assert!(teams.enabled); + } + + /// Verifies that a TOML block missing client_secret (simulating a handler + /// bug) results in None — the loader disables configs with missing credentials. + #[test] + fn test_teams_handler_toml_missing_secret_disabled() { + let config = parse_config( + r#" +[messaging.teams] +enabled = true +app_id = "test-app-id" +tenant_id = "test-tenant" +"#, + ); + + assert!( + config.messaging.teams.is_none(), + "teams config without client_secret should resolve to None" + ); + } + + /// Debug output must not expose the client_secret value. + #[test] + fn test_teams_config_debug_redacts_secret() { + let config = parse_config( + r#" +[messaging.teams] +enabled = true +app_id = "my-app-id" +client_secret = "super-secret-value" +tenant_id = "my-tenant" +"#, + ); + + let teams = config.messaging.teams.as_ref().unwrap(); + let debug_output = format!("{:?}", teams); + assert!( + !debug_output.contains("super-secret-value"), + "Debug output must not contain the raw client_secret" + ); + assert!( + debug_output.contains("[REDACTED]"), + "Debug output should contain [REDACTED] in place of client_secret" + ); + } +} diff --git a/src/config/permissions.rs b/src/config/permissions.rs index 95323e8c0..c80a0f59f 100644 --- a/src/config/permissions.rs +++ b/src/config/permissions.rs @@ -1,7 +1,8 @@ use super::{ Binding, DiscordConfig, DiscordInstanceConfig, MattermostConfig, MattermostInstanceConfig, - SignalConfig, SignalInstanceConfig, SlackConfig, SlackInstanceConfig, TelegramConfig, - TelegramInstanceConfig, TwitchConfig, TwitchInstanceConfig, + SignalConfig, SignalInstanceConfig, SlackConfig, SlackInstanceConfig, TeamsConfig, + TeamsInstanceConfig, TelegramConfig, TelegramInstanceConfig, TwitchConfig, + TwitchInstanceConfig, }; use std::collections::HashMap; @@ -531,6 +532,124 @@ impl MattermostPermissions { } } +/// Hot-reloadable Microsoft Teams permission filters. +/// +/// Derived from bindings + Teams config. Shared with the Teams adapter +/// via `Arc>` so the file watcher can swap in new values without +/// restarting the webhook listener. +/// +/// Teams bindings do not carry a workspace/tenant grouping key (unlike Slack's +/// `workspace_id`), so `channel_filter` is a flat optional list rather than a +/// per-workspace map. +#[derive(Debug, Clone, Default)] +pub struct TeamsPermissions { + /// Allowed Teams channel IDs (None = all channels accepted). + pub channel_filter: Option>, + /// AAD object IDs (or UPN strings) allowed to DM the bot. + /// Empty = DMs blocked entirely. + pub dm_allowed_users: Vec, +} + +impl TeamsPermissions { + /// Build from the current config's Teams settings and bindings. + pub fn from_config(teams: &TeamsConfig, bindings: &[Binding]) -> Self { + Self::from_bindings_for_adapter(teams.dm_allowed_users.clone(), bindings, None) + } + + /// Build permissions for a named Teams adapter instance. + pub fn from_instance_config(instance: &TeamsInstanceConfig, bindings: &[Binding]) -> Self { + Self::from_bindings_for_adapter( + instance.dm_allowed_users.clone(), + bindings, + Some(instance.name.as_str()), + ) + } + + fn from_bindings_for_adapter( + seed_dm_allowed_users: Vec, + bindings: &[Binding], + adapter_selector: Option<&str>, + ) -> Self { + let teams_bindings: Vec<&Binding> = bindings + .iter() + .filter(|binding| { + binding.channel == "teams" + && binding_adapter_selector_matches(binding, adapter_selector) + }) + .collect(); + + // Teams bindings carry no workspace/tenant grouping key, so we collect + // a flat list of allowed channel IDs across all matching bindings. + let channel_filter = { + let channel_ids: Vec = teams_bindings + .iter() + .flat_map(|b| b.channel_ids.clone()) + .collect(); + if channel_ids.is_empty() { + None + } else { + Some(channel_ids) + } + }; + + let mut dm_allowed_users = seed_dm_allowed_users; + + for binding in &teams_bindings { + for id in &binding.dm_allowed_users { + if !dm_allowed_users.contains(id) { + dm_allowed_users.push(id.clone()); + } + } + } + + Self { + channel_filter, + dm_allowed_users, + } + } + + /// Decide whether an inbound Teams activity should be dispatched. + /// + /// # Arguments + /// + /// * `conversation_type` – the `conversationType` claim from the Activity + /// (`"personal"` for DMs, `"channel"` / `"groupChat"` for team channels). + /// `None` is treated as a channel message. + /// * `sender_id` – the AAD object ID of the sender. + /// * `channel_id` – the Teams channel ID (from `activity.conversation.id`). + /// + /// # Decision rules + /// + /// - **DM (`"personal"`):** allowed iff `dm_allowed_users` is non-empty and + /// contains `sender_id`. Empty list = all DMs blocked. + /// - **Channel / other:** allowed iff `channel_filter` is `None` (open) + /// **or** `channel_filter` contains `channel_id`. + pub fn is_allowed( + &self, + conversation_type: Option<&str>, + sender_id: &str, + channel_id: &str, + ) -> bool { + if conversation_type == Some("personal") { + // DM path — fail-closed: block when list is empty or sender absent. + // A `"*"` entry is an explicit allow-all-DMs wildcard (mirrors + // Signal); an empty list still blocks every DM. + if self.dm_allowed_users.is_empty() { + return false; + } + self.dm_allowed_users + .iter() + .any(|u| u == "*" || u == sender_id) + } else { + // Channel / groupChat path. + match &self.channel_filter { + None => true, + Some(allowed) => allowed.iter().any(|c| c == channel_id), + } + } + } +} + fn binding_adapter_selector_matches(binding: &Binding, adapter_selector: Option<&str>) -> bool { match (binding.adapter.as_deref(), adapter_selector) { (None, None) => true, @@ -559,6 +678,137 @@ fn is_valid_base64(s: &str) -> bool { || STANDARD.decode(trimmed).is_ok() } +#[cfg(test)] +mod teams_permissions_tests { + use super::*; + use crate::config::types::{TeamsConfig, TeamsInstanceConfig}; + + fn make_teams_config(dm_allowed_users: Vec<&str>) -> TeamsConfig { + TeamsConfig { + enabled: true, + app_id: "app-id".into(), + client_secret: "secret".into(), + tenant_id: "common".into(), + port: 3979, + bind: "0.0.0.0".into(), + instances: vec![], + dm_allowed_users: dm_allowed_users.into_iter().map(String::from).collect(), + } + } + + fn make_teams_instance_config(name: &str, dm_allowed_users: Vec<&str>) -> TeamsInstanceConfig { + TeamsInstanceConfig { + name: name.into(), + enabled: true, + app_id: "app-id".into(), + client_secret: "secret".into(), + tenant_id: "common".into(), + dm_allowed_users: dm_allowed_users.into_iter().map(String::from).collect(), + } + } + + /// A DM sender present in dm_allowed_users is allowed. + #[test] + fn dm_allowed_user_is_permitted() { + let config = make_teams_config(vec!["user-aad-123"]); + let perms = TeamsPermissions::from_config(&config, &[]); + assert!( + perms.dm_allowed_users.contains(&"user-aad-123".to_string()), + "user-aad-123 should be in dm_allowed_users" + ); + } + + /// A DM sender NOT in dm_allowed_users is denied. + #[test] + fn dm_unknown_user_is_denied() { + let config = make_teams_config(vec!["user-aad-123"]); + let perms = TeamsPermissions::from_config(&config, &[]); + assert!( + !perms.dm_allowed_users.contains(&"unknown-user".to_string()), + "unknown-user should not be in dm_allowed_users" + ); + } + + /// Empty dm_allowed_users means DMs are blocked (no users permitted). + #[test] + fn empty_dm_allowed_users_blocks_all() { + let config = make_teams_config(vec![]); + let perms = TeamsPermissions::from_config(&config, &[]); + assert!( + perms.dm_allowed_users.is_empty(), + "empty dm_allowed_users should remain empty (block all DMs)" + ); + } + + /// channel_filter is None when no bindings provide channel IDs. + #[test] + fn no_bindings_yields_no_channel_filter() { + let config = make_teams_config(vec![]); + let perms = TeamsPermissions::from_config(&config, &[]); + assert!( + perms.channel_filter.is_none(), + "channel_filter should be None when no bindings specify channel IDs" + ); + } + + /// A `"*"` DM wildcard allows any DM sender (mirrors Signal), while an + /// empty list still blocks all DMs and a specific list stays exact-match. + #[test] + fn dm_wildcard_allows_any_sender() { + let wildcard = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec!["*".to_string()], + }; + assert!( + wildcard.is_allowed(Some("personal"), "anyone-at-all", ""), + "\"*\" wildcard must allow an arbitrary DM sender" + ); + assert!( + wildcard.is_allowed(Some("personal"), "29:another-random-mri", ""), + "\"*\" wildcard must allow a second arbitrary DM sender" + ); + + let empty = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec![], + }; + assert!( + !empty.is_allowed(Some("personal"), "anyone", ""), + "empty dm_allowed_users must still block all DMs (no implicit wildcard)" + ); + + let specific = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec!["user-aad-123".to_string()], + }; + assert!( + specific.is_allowed(Some("personal"), "user-aad-123", ""), + "a specifically-listed user is still allowed" + ); + assert!( + !specific.is_allowed(Some("personal"), "someone-else", ""), + "a non-listed user is still denied when there is no wildcard" + ); + } + + /// from_instance_config wires dm_allowed_users from the instance. + #[test] + fn instance_config_dm_allowed_users() { + let instance = make_teams_instance_config("prod", vec!["instance-user-456"]); + let perms = TeamsPermissions::from_instance_config(&instance, &[]); + assert!( + perms + .dm_allowed_users + .contains(&"instance-user-456".to_string()), + "instance-user-456 should be in dm_allowed_users from instance config" + ); + assert!( + !perms.dm_allowed_users.contains(&"other-user".to_string()), + "other-user should not be allowed" + ); + } +} + #[cfg(test)] mod base64_tests { use super::is_valid_base64; diff --git a/src/config/toml_schema.rs b/src/config/toml_schema.rs index b336ad354..7f3ec01f1 100644 --- a/src/config/toml_schema.rs +++ b/src/config/toml_schema.rs @@ -534,6 +534,8 @@ pub(super) struct TomlMessagingConfig { pub(super) signal: Option, #[serde(default)] pub(super) mattermost: Option, + #[serde(default)] + pub(super) teams: Option, } #[derive(Deserialize)] @@ -866,3 +868,40 @@ pub(super) struct TomlMattermostInstanceConfig { pub(super) fn default_mattermost_max_attachment_bytes() -> usize { 10 * 1024 * 1024 } + +#[derive(Deserialize)] +pub(super) struct TomlTeamsConfig { + #[serde(default)] + pub(super) enabled: bool, + pub(super) app_id: Option, + pub(super) client_secret: Option, + pub(super) tenant_id: Option, + #[serde(default = "default_teams_port")] + pub(super) port: u16, + #[serde(default = "default_teams_bind")] + pub(super) bind: String, + #[serde(default)] + pub(super) instances: Vec, + #[serde(default)] + pub(super) dm_allowed_users: Vec, +} + +#[derive(Deserialize)] +pub(super) struct TomlTeamsInstanceConfig { + pub(super) name: String, + #[serde(default)] + pub(super) enabled: bool, + pub(super) app_id: Option, + pub(super) client_secret: Option, + pub(super) tenant_id: Option, + #[serde(default)] + pub(super) dm_allowed_users: Vec, +} + +pub(super) fn default_teams_port() -> u16 { + 3979 +} + +pub(super) fn default_teams_bind() -> String { + "0.0.0.0".into() +} diff --git a/src/config/types.rs b/src/config/types.rs index 65d646c8f..a025810ef 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1980,7 +1980,7 @@ pub(super) struct AdapterValidationState { pub(super) fn is_named_adapter_platform(platform: &str) -> bool { matches!( platform, - "discord" | "slack" | "telegram" | "twitch" | "email" | "signal" | "mattermost" + "discord" | "slack" | "telegram" | "twitch" | "email" | "signal" | "mattermost" | "teams" ) } @@ -2282,6 +2282,34 @@ pub(super) fn build_adapter_validation_states( ); } + if let Some(teams) = &messaging.teams { + validate_instance_names( + "teams", + teams + .instances + .iter() + .map(|instance| instance.name.as_str()), + )?; + let named_instances: std::collections::HashSet = teams + .instances + .iter() + .filter(|i| i.enabled) + .map(|i| i.name.clone()) + .collect(); + let default_present = teams.enabled + && !teams.app_id.trim().is_empty() + && !teams.client_secret.trim().is_empty() + && !teams.tenant_id.trim().is_empty(); + validate_runtime_keys("teams", default_present, &named_instances)?; + states.insert( + "teams", + AdapterValidationState { + default_present, + named_instances, + }, + ); + } + Ok(states) } @@ -2473,6 +2501,7 @@ pub struct MessagingConfig { pub twitch: Option, pub signal: Option, pub mattermost: Option, + pub teams: Option, } #[derive(Clone)] @@ -3147,6 +3176,115 @@ impl std::fmt::Debug for MattermostInstanceConfig { } } +// --------------------------------------------------------------------------- +// Teams config +// --------------------------------------------------------------------------- + +/// Microsoft Teams messaging adapter configuration. +/// +/// Teams uses the Bot Framework webhook model: the adapter opens an HTTP server +/// (`bind`:`port`) that receives Activity payloads from the Bot Framework +/// service. `app_id`, `client_secret`, and `tenant_id` are used for OAuth token +/// validation and proactive messaging. +#[derive(Clone)] +pub struct TeamsConfig { + pub enabled: bool, + /// Azure AD application (client) ID for the bot registration. + pub app_id: String, + /// Azure AD application client secret. Redacted in Debug output. + pub client_secret: String, + /// Azure AD tenant ID. Use "common" for multi-tenant bots. + pub tenant_id: String, + /// Port for the Teams webhook listener (default 3979). + pub port: u16, + /// Address to bind the Teams webhook listener on (default "0.0.0.0"). + pub bind: String, + /// Additional named Teams bot instances for this platform. + pub instances: Vec, + /// User IDs allowed to DM the bot. If empty, DMs are ignored entirely. + pub dm_allowed_users: Vec, +} + +/// A single named Teams bot instance (multi-tenant / multi-app deployments). +#[derive(Clone)] +pub struct TeamsInstanceConfig { + pub name: String, + pub enabled: bool, + pub app_id: String, + /// Azure AD application client secret. Redacted in Debug output. + pub client_secret: String, + pub tenant_id: String, + /// User IDs allowed to DM this bot instance. + pub dm_allowed_users: Vec, +} + +impl std::fmt::Debug for TeamsInstanceConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TeamsInstanceConfig") + .field("name", &self.name) + .field("enabled", &self.enabled) + .field("app_id", &self.app_id) + .field("client_secret", &"[REDACTED]") + .field("tenant_id", &self.tenant_id) + .field("dm_allowed_users", &self.dm_allowed_users) + .finish() + } +} + +impl std::fmt::Debug for TeamsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TeamsConfig") + .field("enabled", &self.enabled) + .field("app_id", &self.app_id) + .field("client_secret", &"[REDACTED]") + .field("tenant_id", &self.tenant_id) + .field("port", &self.port) + .field("bind", &self.bind) + .field("instances", &self.instances) + .field("dm_allowed_users", &self.dm_allowed_users) + .finish() + } +} + +impl SystemSecrets for TeamsConfig { + fn section() -> &'static str { + "teams" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[ + SecretField { + toml_key: "client_secret", + secret_name: "TEAMS_CLIENT_SECRET", + instance_pattern: Some(InstancePattern { + platform_prefix: "TEAMS", + field_suffix: "CLIENT_SECRET", + }), + }, + SecretField { + toml_key: "app_id", + secret_name: "TEAMS_APP_ID", + instance_pattern: Some(InstancePattern { + platform_prefix: "TEAMS", + field_suffix: "APP_ID", + }), + }, + SecretField { + toml_key: "tenant_id", + secret_name: "TEAMS_TENANT_ID", + instance_pattern: Some(InstancePattern { + platform_prefix: "TEAMS", + field_suffix: "TENANT_ID", + }), + }, + ] + } +} + #[cfg(test)] mod mattermost_url_tests { use super::validate_mattermost_url; diff --git a/src/config/watcher.rs b/src/config/watcher.rs index f197897d5..8f23d1e23 100644 --- a/src/config/watcher.rs +++ b/src/config/watcher.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use super::{ Binding, Config, DiscordPermissions, MattermostPermissions, RuntimeConfig, SignalPermissions, - SlackPermissions, TelegramPermissions, TwitchPermissions, binding_runtime_adapter_key, + SlackPermissions, TeamsPermissions, TelegramPermissions, TwitchPermissions, + binding_runtime_adapter_key, }; /// Per-agent context needed by the file watcher: (id, prompt_dir, identity_dir, @@ -33,6 +34,7 @@ pub fn spawn_file_watcher( twitch_permissions: Option>>, mattermost_permissions: Option>>, signal_permissions: Option>>, + teams_permissions: Option>>, bindings: Arc>>, messaging_manager: Option>, llm_manager: Arc, @@ -259,6 +261,14 @@ pub fn spawn_file_watcher( tracing::info!("signal permissions reloaded"); } + if let Some(ref perms) = teams_permissions + && let Some(teams_config) = &config.messaging.teams + { + let new_perms = TeamsPermissions::from_config(teams_config, &config.bindings); + perms.store(Arc::new(new_perms)); + tracing::info!("teams permissions reloaded"); + } + // Hot-start adapters that are newly enabled in the config if let Some(ref manager) = messaging_manager { let rt = tokio::runtime::Handle::current(); @@ -270,6 +280,7 @@ pub fn spawn_file_watcher( let twitch_permissions = twitch_permissions.clone(); let mattermost_permissions = mattermost_permissions.clone(); let signal_permissions = signal_permissions.clone(); + let teams_permissions = teams_permissions.clone(); let instance_dir = instance_dir.clone(); rt.spawn(async move { @@ -684,6 +695,44 @@ pub fn spawn_file_watcher( } } } + + // Teams: start default instance only (single-instance in v1). + if let Some(teams_config) = &config.messaging.teams + && teams_config.enabled + { + if !teams_config.app_id.is_empty() + && !teams_config.client_secret.is_empty() + && !teams_config.tenant_id.is_empty() + && !manager.has_adapter("teams").await + { + let permissions = match teams_permissions { + Some(ref existing) => existing.clone(), + None => { + let permissions = TeamsPermissions::from_config(teams_config, &config.bindings); + Arc::new(arc_swap::ArcSwap::from_pointee(permissions)) + } + }; + match crate::messaging::teams::build_teams_adapter( + "teams", + &teams_config.app_id, + &teams_config.client_secret, + &teams_config.tenant_id, + teams_config.port, + &teams_config.bind, + permissions, + &instance_dir, + ) { + Ok(adapter) => { + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, "failed to hot-start teams adapter from config change"); + } + } + Err(error) => { + tracing::error!(%error, "failed to build teams adapter from config change"); + } + } + } + } }); } } diff --git a/src/conversation/channels.rs b/src/conversation/channels.rs index 716bd82a7..7ad0ac49a 100644 --- a/src/conversation/channels.rs +++ b/src/conversation/channels.rs @@ -336,6 +336,16 @@ fn extract_platform_meta( } } } + "teams" => { + // Store service URL and conversation type; teams_conversation_id is intentionally + // omitted so that resolve_broadcast_target always uses its prefix-stripping fallback, + // which correctly preserves the `:instance` suffix for named-instance channels. + for key in ["teams_service_url", "teams_conversation_type"] { + if let Some(value) = metadata.get(key) { + meta.insert(key.to_string(), value.clone()); + } + } + } _ => {} } @@ -419,6 +429,49 @@ mod tests { ); } + #[test] + fn extract_platform_meta_teams_stores_service_url_and_conversation_type() { + let metadata: std::collections::HashMap = [ + ( + "teams_service_url".to_string(), + serde_json::Value::String("https://smba.trafficmanager.net/amer/".into()), + ), + ( + "teams_conversation_type".to_string(), + serde_json::Value::String("channel".into()), + ), + ( + "teams_conversation_id".to_string(), + serde_json::Value::String("19:abc123@thread.tacv2".into()), + ), + ] + .into_iter() + .collect(); + + let result = + extract_platform_meta("teams", &metadata).expect("teams metadata should produce Some"); + + let parsed: serde_json::Value = + serde_json::from_str(&result).expect("result should be valid JSON"); + + assert_eq!( + parsed.get("teams_service_url").and_then(|v| v.as_str()), + Some("https://smba.trafficmanager.net/amer/") + ); + assert_eq!( + parsed + .get("teams_conversation_type") + .and_then(|v| v.as_str()), + Some("channel") + ); + // teams_conversation_id is intentionally not stored — resolve_broadcast_target + // uses prefix-stripping which handles named instances correctly. + assert!( + parsed.get("teams_conversation_id").is_none(), + "teams_conversation_id must not be stored in platform_meta" + ); + } + #[tokio::test] async fn set_active_toggles_channel_state_without_deleting() { let store = setup_store().await; diff --git a/src/main.rs b/src/main.rs index da784382e..b3d05d1fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1800,6 +1800,7 @@ async fn run( let mut telegram_permissions = None; let mut twitch_permissions = None; let mut mattermost_permissions = None; + let mut teams_permissions = None; let mut signal_permissions = None; initialize_agents( &config, @@ -1820,6 +1821,7 @@ async fn run( &mut twitch_permissions, &mut mattermost_permissions, &mut signal_permissions, + &mut teams_permissions, agent_links.clone(), agent_humans.clone(), injection_tx.clone(), @@ -1843,6 +1845,7 @@ async fn run( twitch_permissions, mattermost_permissions, signal_permissions, + teams_permissions, bindings.clone(), Some(messaging_manager.clone()), llm_manager.clone(), @@ -1861,6 +1864,7 @@ async fn run( None, // twitch_permissions None, // mattermost_permissions None, // signal_permissions + None, // teams_permissions bindings.clone(), None, llm_manager.clone(), @@ -2585,6 +2589,7 @@ async fn run( let mut new_telegram_permissions = None; let mut new_twitch_permissions = None; let mut new_mattermost_permissions = None; + let mut new_teams_permissions = None; let mut new_signal_permissions = None; match initialize_agents( &new_config, @@ -2605,6 +2610,7 @@ async fn run( &mut new_twitch_permissions, &mut new_mattermost_permissions, &mut new_signal_permissions, + &mut new_teams_permissions, agent_links.clone(), agent_humans.clone(), injection_tx.clone(), @@ -2627,6 +2633,7 @@ async fn run( new_twitch_permissions, new_mattermost_permissions, new_signal_permissions, + new_teams_permissions, bindings.clone(), Some(messaging_manager.clone()), new_llm_manager.clone(), @@ -2751,6 +2758,7 @@ async fn initialize_agents( twitch_permissions: &mut Option>>, mattermost_permissions: &mut Option>>, signal_permissions: &mut Option>>, + teams_permissions: &mut Option>>, agent_links: Arc>>, agent_humans: Arc>>, injection_tx: tokio::sync::mpsc::Sender, @@ -3563,6 +3571,52 @@ async fn initialize_agents( } } + // Shared Teams permissions (hot-reloadable via file watcher) + *teams_permissions = config.messaging.teams.as_ref().map(|teams_config| { + let perms = spacebot::config::TeamsPermissions::from_config(teams_config, &config.bindings); + Arc::new(ArcSwap::from_pointee(perms)) + }); + + if let Some(teams_config) = &config.messaging.teams + && teams_config.enabled + { + if !teams_config.app_id.is_empty() + && !teams_config.client_secret.is_empty() + && !teams_config.tenant_id.is_empty() + { + match spacebot::messaging::teams::build_teams_adapter( + "teams", + &teams_config.app_id, + &teams_config.client_secret, + &teams_config.tenant_id, + teams_config.port, + &teams_config.bind, + teams_permissions.clone().ok_or_else(|| { + anyhow::anyhow!("teams permissions not initialized when teams is enabled") + })?, + &config.instance_dir, + ) { + Ok(adapter) => { + new_messaging_manager.register(adapter).await; + } + Err(error) => { + tracing::error!(%error, "failed to build teams adapter"); + } + } + } + + // v1 supports a single Teams listener per port. Named [[messaging.teams.instances]] + // share the same port as the default instance and cannot each bind their own + // listener, so they are skipped with a clear warning rather than retried 12× + // and silently dropped by the manager. See docs/design-docs/teams-setup.md. + if teams_config.instances.iter().any(|i| i.enabled) { + tracing::warn!( + "Teams v1 supports a single listener per port; named [[messaging.teams.instances]] \ + are NOT started — see docs/design-docs/teams-setup.md" + ); + } + } + // Shared Signal permissions (hot-reloadable via file watcher) *signal_permissions = config.messaging.signal.as_ref().map(|signal_config| { let perms = spacebot::config::SignalPermissions::from_config(signal_config); diff --git a/src/messaging.rs b/src/messaging.rs index b3c434e22..d84fbaf43 100644 --- a/src/messaging.rs +++ b/src/messaging.rs @@ -1,4 +1,4 @@ -//! Messaging adapters (Discord, Slack, Telegram, Twitch, Signal, Email, Webhook, Portal, Mattermost). +//! Messaging adapters (Discord, Slack, Telegram, Twitch, Signal, Email, Webhook, Portal, Mattermost, Teams). pub mod discord; pub mod email; @@ -8,6 +8,7 @@ pub mod portal; pub mod signal; pub mod slack; pub mod target; +pub mod teams; pub mod telegram; pub mod traits; pub mod twitch; diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 3960fa398..1227af058 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -29,9 +29,13 @@ pub fn parse_delivery_target(raw: &str) -> Option { return parse_signal_target_parts(parts.get(1..).unwrap_or(&[])); } - // Handle other platforms with named instances (telegram, discord, slack) + // Handle other platforms with named instances (telegram, discord, slack, teams) // Format: platform:: or platform: - if raw.starts_with("telegram:") || raw.starts_with("discord:") || raw.starts_with("slack:") { + if raw.starts_with("telegram:") + || raw.starts_with("discord:") + || raw.starts_with("slack:") + || raw.starts_with("teams:") + { let parts: Vec<&str> = raw.split(':').collect(); return parse_named_instance_target(&parts); } @@ -159,6 +163,57 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option { + // Teams conversation IDs can contain colons (e.g. "19:meeting_abc==@thread.tacv2"). + // We must NOT split greedily — instead strip only the known platform (and optional + // instance) prefix verbatim and keep the remainder as the bare conversation id. + // + // Channel id formats: + // "teams:{conversation_id}" → adapter "teams" + // "teams:{instance}:{conversation_id}" → adapter "teams:{instance}" + // + // The `teams_conversation_id` metadata key is preferred when present so we can + // round-trip correctly even if the channel id is malformed. + if let Some(conv_id) = channel + .platform_meta + .as_ref() + .and_then(|meta| meta.get("teams_conversation_id")) + .and_then(json_value_to_string) + { + return Some(BroadcastTarget { + adapter: adapter.to_string(), + target: conv_id, + }); + } + + // Derive the bare conversation id from the channel id by stripping the prefix. + // Channel id is "teams:{rest}" or "teams:{instance}:{rest}". + // We need to figure out whether {rest} starts with a valid instance name followed + // by another colon. Use the same heuristic as parse_named_instance_target. + let after_teams = channel.id.strip_prefix("teams:").unwrap_or(&channel.id); + // Check whether the first segment (up to the next ':') is a valid instance name. + let (resolved_adapter, bare_conv_id) = + if let Some((maybe_instance, rest)) = after_teams.split_once(':') { + if is_valid_instance_name(maybe_instance) { + (format!("teams:{maybe_instance}"), rest.to_string()) + } else { + // The "instance" segment looks like a real conversation id start — + // keep the whole `after_teams` as the bare conversation id. + ("teams".to_string(), after_teams.to_string()) + } + } else { + ("teams".to_string(), after_teams.to_string()) + }; + + if bare_conv_id.is_empty() { + return None; + } + + return Some(BroadcastTarget { + adapter: resolved_adapter, + target: bare_conv_id, + }); + } "mattermost" => { let adapter = extract_mattermost_adapter_from_channel_id(&channel.id); let raw_target = if let Some(channel_id) = channel @@ -212,6 +267,8 @@ pub fn normalize_target(adapter: &str, raw_target: &str) -> Option { // Portal targets are full conversation IDs (e.g. "portal:chat:main") "portal" => Some(trimmed.to_string()), "signal" => normalize_signal_target(trimmed), + // Teams conversation IDs are opaque strings (may contain ':' and ';') — pass verbatim. + "teams" => normalize_teams_target(trimmed), _ => Some(trimmed.to_string()), } } @@ -344,6 +401,20 @@ fn normalize_mattermost_target(raw_target: &str) -> Option { } } +/// Normalize a raw Teams conversation id. +/// +/// Teams conversation IDs are opaque strings that may contain `:`, `;`, `@`, `=`, and other +/// characters. They are passed through verbatim after stripping any leading `teams:` prefix +/// (which can be left over from double-prefixed strings) and rejecting empty inputs. +fn normalize_teams_target(raw_target: &str) -> Option { + let target = strip_repeated_prefix(raw_target, "teams"); + if target.is_empty() { + None + } else { + Some(target.to_string()) + } +} + fn normalize_email_target(raw_target: &str) -> Option { let target = strip_repeated_prefix(raw_target, "email").trim(); if target.is_empty() { @@ -578,12 +649,14 @@ pub fn parse_signal_target_parts(parts: &[&str]) -> Option { } } -/// Parse targets for platforms with named instance support (telegram, discord, slack). +/// Parse targets for platforms with named instance support (telegram, discord, slack, teams). /// /// Handles formats: -/// - Default adapter: ["telegram", target], ["discord", target], ["slack", target] +/// - Default adapter: ["telegram", target], ["discord", target], ["slack", target], ["teams", target] /// - Legacy format: ["discord", guild_id, channel_id], ["slack", workspace_id, channel_id] -/// - Named adapter: ["telegram", instance, target], ["discord", instance, target], ["slack", instance, target] +/// - Named adapter: ["telegram", instance, target], ["discord", instance, target], +/// ["slack", instance, target], ["teams", instance, target] +/// - Teams multi-part IDs: ["teams", segment, ...] where the conversation id contains colons /// /// Returns None for invalid formats. fn parse_named_instance_target(parts: &[&str]) -> Option { @@ -595,8 +668,9 @@ fn parse_named_instance_target(parts: &[&str]) -> Option { let is_telegram = platform == "telegram"; let is_discord = platform == "discord"; let is_slack = platform == "slack"; + let is_teams = platform == "teams"; - if !is_telegram && !is_discord && !is_slack { + if !is_telegram && !is_discord && !is_slack && !is_teams { return None; } @@ -647,6 +721,21 @@ fn parse_named_instance_target(parts: &[&str]) -> Option { target: normalized, }) } + // Teams multi-part conversation IDs: teams:{rest} where rest contains colons. + // The second segment was NOT a valid instance name (handled by the named-adapter arm + // above), so the entire `parts[1..]` sequence is the bare conversation id. + // We reconstruct it verbatim via join to preserve the colons. + ["teams", ..] if parts.len() > 2 => { + let conv_id = parts[1..].join(":"); + if conv_id.is_empty() { + return None; + } + let normalized = normalize_teams_target(&conv_id)?; + Some(BroadcastTarget { + adapter: "teams".to_string(), + target: normalized, + }) + } // Default adapter: platform:target [platform, target] if !target.is_empty() => { let normalized = normalize_target(platform, target)?; @@ -1098,4 +1187,111 @@ mod tests { // 21 characters (over boundary) assert!(!super::is_valid_instance_name("exactly_twenty_chars_")); } + + // --------------------------------------------------------------------------- + // Teams tests + // --------------------------------------------------------------------------- + + /// Simple `teams:{id}` default round-trips correctly. + #[test] + fn parse_teams_default_target() { + let parsed = parse_delivery_target("teams:conv-dm-001"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "teams".to_string(), + target: "conv-dm-001".to_string(), + }) + ); + } + + /// `teams:{instance}:{id}` named round-trips with correct adapter and target. + #[test] + fn parse_teams_named_instance_target() { + let parsed = parse_delivery_target("teams:prod:conv-ch-999"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "teams:prod".to_string(), + target: "conv-ch-999".to_string(), + }) + ); + } + + /// Conversation ID containing a colon is preserved verbatim, not truncated. + #[test] + fn parse_teams_conversation_id_with_colon_preserved() { + let parsed = parse_delivery_target("teams:19:meeting_abc==@thread.tacv2"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "teams".to_string(), + target: "19:meeting_abc==@thread.tacv2".to_string(), + }) + ); + } + + /// Named instance + conversation ID containing a colon. + #[test] + fn parse_teams_named_instance_with_colon_in_id() { + let parsed = parse_delivery_target("teams:prod:19:meeting_abc==@thread.tacv2"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "teams:prod".to_string(), + target: "19:meeting_abc==@thread.tacv2".to_string(), + }) + ); + } + + /// `teams:` with no conversation id is rejected. + #[test] + fn parse_teams_empty_conversation_id_rejected() { + assert!(parse_delivery_target("teams:").is_none()); + } + + /// `resolve_broadcast_target` for a teams channel returns the bare conversation id. + #[test] + fn resolve_teams_broadcast_target_from_channel_id() { + let channel = test_channel_info("teams:19:meeting_abc==@thread.tacv2", "teams"); + let resolved = resolve_broadcast_target(&channel); + assert_eq!( + resolved, + Some(super::BroadcastTarget { + adapter: "teams".to_string(), + target: "19:meeting_abc==@thread.tacv2".to_string(), + }) + ); + } + + /// `resolve_broadcast_target` for a named-instance teams channel returns the correct adapter. + #[test] + fn resolve_teams_named_instance_broadcast_target() { + let channel = test_channel_info("teams:prod:19:meeting_abc==@thread.tacv2", "teams"); + let resolved = resolve_broadcast_target(&channel); + assert_eq!( + resolved, + Some(super::BroadcastTarget { + adapter: "teams:prod".to_string(), + target: "19:meeting_abc==@thread.tacv2".to_string(), + }) + ); + } + + /// `resolve_broadcast_target` prefers `teams_conversation_id` from platform_meta when present. + #[test] + fn resolve_teams_broadcast_target_prefers_meta() { + let mut channel = test_channel_info("teams:old-conv-id", "teams"); + channel.platform_meta = Some(serde_json::json!({ + "teams_conversation_id": "19:canonical_conv_id@thread.tacv2" + })); + let resolved = resolve_broadcast_target(&channel); + assert_eq!( + resolved, + Some(super::BroadcastTarget { + adapter: "teams".to_string(), + target: "19:canonical_conv_id@thread.tacv2".to_string(), + }) + ); + } } diff --git a/src/messaging/teams.rs b/src/messaging/teams.rs new file mode 100644 index 000000000..be84b980f --- /dev/null +++ b/src/messaging/teams.rs @@ -0,0 +1,3074 @@ +//! Microsoft Teams messaging adapter (Bot Framework). +//! +//! This module provides: +//! - Outbound Azure AD token provider (mint/cache Bot Connector bearer tokens). +//! - Inbound JWT validator: verifies that incoming POST /api/messages requests +//! are signed by Azure Bot Service, preventing message injection by third +//! parties. +//! - Bot Framework Activity deserialization and normalization to `InboundMessage`. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; + +use anyhow::Context as _; +use jsonwebtoken::{ + Algorithm, DecodingKey, Header, Validation, decode, decode_header, jwk::JwkSet, +}; +use reqwest::Client; +use serde::Deserialize; +use tokio::sync::{Mutex, RwLock}; +use url::Url; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// How long before the token actually expires we consider it stale and refresh +/// proactively. 5 minutes matches common Azure AD guidance. +const REFRESH_LEEWAY: Duration = Duration::from_secs(300); + +/// Azure AD token endpoint template — `{tenant}` is replaced at runtime. +const TOKEN_ENDPOINT: &str = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"; + +/// The scope required to call the Bot Connector service. +const BOT_FRAMEWORK_SCOPE: &str = "https://api.botframework.com/.default"; + +// --------------------------------------------------------------------------- +// Inbound JWT validation constants +// --------------------------------------------------------------------------- + +/// The OpenID Connect metadata endpoint for Azure Bot Framework. +/// Source: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication +/// (Connector to Bot authentication — protocol v3.1 & v3.2) +const BOT_FRAMEWORK_OPENID_CONFIG: &str = + "https://login.botframework.com/v1/.well-known/openidconfiguration"; + +/// The issuer claim that Azure Bot Service includes in tokens it sends to bots. +/// Source: MS docs table "JWT Issuer" under "Connector to Bot authentication". +const BOT_FRAMEWORK_ISSUER: &str = "https://api.botframework.com"; + +/// JWKS cache is considered stale after 24 h (per Microsoft guidance). +const JWKS_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +// --------------------------------------------------------------------------- +// Internal cache entry +// --------------------------------------------------------------------------- + +struct CachedToken { + /// The opaque bearer token string. + token: String, + /// Absolute instant at which this token expires. + expires_at: Instant, +} + +// --------------------------------------------------------------------------- +// Token response from Azure AD +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + /// Lifetime of the token in seconds. + expires_in: u64, +} + +// --------------------------------------------------------------------------- +// TeamsTokenProvider +// --------------------------------------------------------------------------- + +/// Mints and caches an Azure AD client-credentials bearer token for the Bot +/// Connector service. +/// +/// Concurrent callers that race on an expired token all block on the same +/// mutex acquisition; whichever wins refreshes once and all subsequent waiters +/// read the freshly cached value. This deliberately holds the mutex across +/// the await so there is only ever one in-flight refresh. +/// +/// **Secrets policy:** `client_secret` and the returned token are NEVER +/// written to tracing spans, log fields, or error messages. +pub struct TeamsTokenProvider { + tenant_id: String, + app_id: String, + /// The client secret is stored in memory but must never be logged. + client_secret: String, + http: Client, + cached: Mutex>, +} + +impl TeamsTokenProvider { + /// Construct a new provider from credentials. + pub fn new(tenant_id: String, app_id: String, client_secret: String) -> anyhow::Result { + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build HTTP client for Teams token provider")?; + + Ok(Self { + tenant_id, + app_id, + client_secret, + http, + cached: Mutex::new(None), + }) + } + + /// Return a valid Bot Connector bearer token, refreshing from Azure AD if + /// necessary. + /// + /// The mutex is held across the network call so that concurrent callers + /// coalesce onto a single refresh rather than stampeding the endpoint. + pub async fn bearer(&self) -> anyhow::Result { + let mut guard = self.cached.lock().await; + + let now = Instant::now(); + + // Return the cached token if it is still comfortably valid. + if let Some(ref cached) = *guard + && !needs_refresh(cached.expires_at, now, REFRESH_LEEWAY) + { + return Ok(cached.token.clone()); + } + + // Cache is empty or stale — fetch a new token. + tracing::debug!( + tenant_id = %self.tenant_id, + app_id = %self.app_id, + "refreshing Azure AD token for Bot Connector", + ); + + let endpoint = TOKEN_ENDPOINT.replace("{tenant}", &self.tenant_id); + + let response = self + .http + .post(&endpoint) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.app_id), + ("client_secret", &self.client_secret), + ("scope", BOT_FRAMEWORK_SCOPE), + ]) + .send() + .await + .context("Azure AD token request failed")?; + + let status = response.status(); + if !status.is_success() { + // Read the body for diagnostics but do NOT include secrets. + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + anyhow::bail!("Azure AD token endpoint returned {status}: {body}"); + } + + let token_resp: TokenResponse = response + .json() + .await + .context("failed to deserialise Azure AD token response")?; + + let expires_at = now + Duration::from_secs(token_resp.expires_in); + + tracing::info!( + tenant_id = %self.tenant_id, + app_id = %self.app_id, + expires_in_secs = token_resp.expires_in, + "Azure AD Bot Connector token refreshed", + ); + + let token = token_resp.access_token; + *guard = Some(CachedToken { + token: token.clone(), + expires_at, + }); + + Ok(token) + } +} + +// --------------------------------------------------------------------------- +// Pure helper — separated for unit-testability +// --------------------------------------------------------------------------- + +/// Return `true` if the cached token should be refreshed. +/// +/// Refreshing is needed when `now` is at or past `expires_at - leeway`. +/// +/// # Arguments +/// +/// * `expires_at` – the absolute instant the token expires. +/// * `now` – the current instant (injectable for tests). +/// * `leeway` – how far before expiry we start treating the token as +/// stale (typically [`REFRESH_LEEWAY`]). +#[inline] +pub fn needs_refresh(expires_at: Instant, now: Instant, leeway: Duration) -> bool { + // If expires_at < leeway, checked_sub returns None; fall back to expires_at + // so we trigger an immediate refresh rather than panicking on underflow. + let refresh_after = expires_at.checked_sub(leeway).unwrap_or(expires_at); + now >= refresh_after +} + +// --------------------------------------------------------------------------- +// JWKS cache (inbound JWT validation) +// --------------------------------------------------------------------------- + +/// OpenID Connect configuration document (subset of fields we care about). +#[derive(Deserialize)] +struct OpenIdConfig { + jwks_uri: String, +} + +/// Cached JWKS state. +struct JwksCacheInner { + /// The parsed JWK set fetched from `jwks_uri`. + keyset: JwkSet, + /// Wall-clock time at which this cache entry was populated. + fetched_at: std::time::SystemTime, +} + +/// Thread-safe JWKS cache that fetches keys from the Bot Framework OpenID +/// endpoint and refreshes: +/// - automatically when the cached copy is older than 24 hours, and +/// - on-demand when a `kid` is not found (refresh-once-on-unknown-kid). +/// +/// Constructed once and shared (via `Arc`) across request handlers. +pub struct JwksCache { + http: Client, + inner: RwLock>, +} + +impl JwksCache { + /// Create a new, empty cache. Keys are fetched lazily on first use. + pub fn new() -> anyhow::Result { + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build HTTP client for JWKS cache")?; + Ok(Self { + http, + inner: RwLock::new(None), + }) + } + + /// Return the current JWK set, refreshing if stale (> 24 h) or absent. + /// + /// Uses a write lock only when a refresh is actually required; read-side + /// accesses are concurrent. + pub async fn keyset(&self) -> anyhow::Result> { + // Fast path: read lock. + { + let guard = self.inner.read().await; + if let Some(ref cached) = *guard + && !Self::is_stale(&cached.fetched_at) + { + return Ok(Arc::new(cached.keyset.clone())); + } + } + + // Slow path: write lock — fetch fresh keys. + let mut guard = self.inner.write().await; + // Double-check: another waiter may have refreshed while we waited. + if let Some(ref cached) = *guard + && !Self::is_stale(&cached.fetched_at) + { + return Ok(Arc::new(cached.keyset.clone())); + } + + let keyset = Self::fetch_keyset(&self.http).await?; + let fetched_at = SystemTime::now(); + *guard = Some(JwksCacheInner { + keyset: keyset.clone(), + fetched_at, + }); + Ok(Arc::new(keyset)) + } + + /// Find a key by `kid`, refreshing once if not found. + /// + /// Returns `Err` if the key is still absent after one refresh. + pub async fn find_key(&self, kid: &str) -> anyhow::Result { + let keyset = self.keyset().await?; + if let Some(jwk) = keyset.find(kid) { + return DecodingKey::from_jwk(jwk).context("failed to build DecodingKey from JWK"); + } + + // Refresh once on unknown kid (key rotation). + tracing::debug!(kid, "kid not found in JWKS cache; forcing refresh"); + self.force_refresh().await?; + let keyset = self.keyset().await?; + let jwk = keyset + .find(kid) + .with_context(|| format!("kid '{kid}' not found in JWKS even after refresh"))?; + DecodingKey::from_jwk(jwk).context("failed to build DecodingKey from JWK after refresh") + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + fn is_stale(fetched_at: &SystemTime) -> bool { + fetched_at + .elapsed() + .map(|age| age >= JWKS_TTL) + .unwrap_or(true) + } + + async fn force_refresh(&self) -> anyhow::Result<()> { + let mut guard = self.inner.write().await; + let keyset = Self::fetch_keyset(&self.http).await?; + *guard = Some(JwksCacheInner { + keyset, + fetched_at: SystemTime::now(), + }); + Ok(()) + } + + async fn fetch_keyset(http: &Client) -> anyhow::Result { + // Step 1: Get the OpenID configuration to find `jwks_uri`. + let config: OpenIdConfig = http + .get(BOT_FRAMEWORK_OPENID_CONFIG) + .send() + .await + .context("failed to fetch Bot Framework OpenID config")? + .error_for_status() + .context("Bot Framework OpenID config returned error status")? + .json() + .await + .context("failed to parse Bot Framework OpenID config")?; + + tracing::debug!(jwks_uri = %config.jwks_uri, "fetched Bot Framework OpenID config"); + + // Step 2: Fetch the JWKS from the URI advertised in the config. + let keyset: JwkSet = http + .get(&config.jwks_uri) + .send() + .await + .context("failed to fetch Bot Framework JWKS")? + .error_for_status() + .context("Bot Framework JWKS endpoint returned error status")? + .json() + .await + .context("failed to parse Bot Framework JWKS")?; + + tracing::info!( + key_count = keyset.keys.len(), + "refreshed Bot Framework JWKS signing keys", + ); + + Ok(keyset) + } +} + +// --------------------------------------------------------------------------- +// Inbound JWT validation +// --------------------------------------------------------------------------- + +/// Validate a signed JWT token sent by Azure Bot Service to our webhook. +/// +/// This is the security gate on `/api/messages`. It rejects any request whose +/// `Authorization` header is absent, malformed, signed with a wrong key, has +/// an incorrect issuer/audience, or is expired. +/// +/// # Arguments +/// +/// * `auth_header` – the raw value of the `Authorization` HTTP header. +/// * `app_id` – our Microsoft App ID (used as the expected `aud` claim). +/// * `jwks` – the shared JWKS cache. +/// +/// # Errors +/// +/// Returns `Err` for ANY validation failure. The error messages are safe to +/// log but should NOT be returned verbatim in HTTP responses (avoid leaking +/// token fragments). +pub async fn validate_inbound_jwt( + auth_header: &str, + app_id: &str, + jwks: &JwksCache, +) -> anyhow::Result<()> { + // Strip "Bearer " prefix. + let token = auth_header + .strip_prefix("Bearer ") + .with_context(|| "Authorization header is not a Bearer token")?; + + if token.is_empty() { + anyhow::bail!("Authorization header contains an empty Bearer token"); + } + + // Decode the header only (no signature check) to get the `kid`. + let header: Header = decode_header(token).context("failed to decode JWT header")?; + + let kid = header + .kid + .as_deref() + .context("JWT header missing 'kid' field")?; + + // Look up the signing key (with lazy refresh on unknown kid). + let decoding_key = jwks + .find_key(kid) + .await + .context("JWT signing key not found")?; + + validate_token_with_key(token, app_id, BOT_FRAMEWORK_ISSUER, &decoding_key) +} + +/// Pure, injectable token validator — separated so tests can inject a +/// `DecodingKey` derived from a locally-generated test RSA keypair without +/// hitting any network. +/// +/// # Arguments +/// +/// * `token` – raw JWT string (no "Bearer " prefix). +/// * `expected_aud` – the audience value that must appear in the token's `aud` +/// claim (our bot App ID). +/// * `expected_iss` – the issuer value that must appear in the token's `iss` +/// claim. +/// * `key` – the `DecodingKey` to verify the signature with. +/// +/// # Security invariants +/// +/// - Only `RS256` is accepted; `alg: none`, `HS256`, and all other algorithms +/// are rejected at the `Validation` level — jsonwebtoken refuses any token +/// whose `alg` header does not match the allowed list. +/// - `validate_aud` is **always** `true`. +/// - `validate_exp` is **always** `true` (the default). +/// - Leeway is 300 s (5 min), matching Microsoft's industry-standard guidance. +/// - `exp`, `aud`, and `iss` are all **required** claims — a token that omits +/// any of them is rejected outright, regardless of signature validity. This +/// prevents cross-bot replay attacks using tokens that simply lack an `aud`. +pub fn validate_token_with_key( + token: &str, + expected_aud: &str, + expected_iss: &str, + key: &DecodingKey, +) -> anyhow::Result<()> { + let mut validation = Validation::new(Algorithm::RS256); + + // Audience: must equal our app ID. + validation.set_audience(&[expected_aud]); + validation.validate_aud = true; + + // Issuer: must equal the Bot Framework issuer. + validation.set_issuer(&[expected_iss]); + + // Expiry: validated by default; allow 5 min clock skew. + validation.validate_exp = true; + validation.leeway = 300; + + // Require exp, aud, and iss to be present in the token. jsonwebtoken only + // *validates* claims that exist; without this, a token that simply omits + // `aud` or `iss` would pass the audience/issuer checks entirely. + validation.set_required_spec_claims(&["exp", "aud", "iss"]); + + // Decode and verify in one step. + decode::(token, key, &validation).context("JWT validation failed")?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Bot Framework Activity deserialization +// --------------------------------------------------------------------------- + +/// The `from` / `recipient` identity object in a Bot Framework Activity. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ActivityAccount { + pub id: String, + #[serde(default)] + pub name: String, +} + +/// The `conversation` object in a Bot Framework Activity. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityConversation { + pub id: String, + #[serde(rename = "conversationType")] + pub conversation_type: Option, +} + +/// A Bot Framework attachment entry on an inbound Activity. +/// Tolerant parsing — unknown fields ignored. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TeamsAttachment { + pub content_type: String, + #[serde(default)] + pub content_url: Option, + #[serde(default)] + pub name: Option, + /// Inline `content` object (e.g. file download info, or a card payload). + #[serde(default)] + pub content: serde_json::Value, +} + +/// Subset of the Bot Framework Activity schema used for inbound message +/// processing. Unknown fields are silently ignored (tolerant parsing). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Activity { + /// The activity type, e.g. "message", "conversationUpdate", "typing". + #[serde(rename = "type")] + pub activity_type: String, + + /// Unique activity identifier assigned by the Bot Connector. + #[serde(default)] + pub id: String, + + /// The text body of the message (may be absent for non-message activities). + pub text: Option, + + /// The sender of the activity (the user or service sending to the bot). + pub from: ActivityAccount, + + /// The conversation this activity belongs to. + pub conversation: ActivityConversation, + + /// Base URI of the channel service — used by the outbound adapter to send + /// replies back to the correct Bot Connector endpoint. + #[serde(rename = "serviceUrl")] + pub service_url: String, + + /// The bot (recipient) identity. + pub recipient: ActivityAccount, + + /// Platform-specific extension data. + #[serde(rename = "channelData", default)] + pub channel_data: serde_json::Value, + + /// The ID of the activity this is a reply to, if any. + #[serde(rename = "replyToId")] + pub reply_to_id: Option, + + /// Mention entities and other structured data attached to the activity. + #[serde(default)] + pub entities: Option>, + + /// Inbound attachments (uploaded files, inline images, cards). + #[serde(default)] + pub attachments: Option>, + + /// Adaptive Card `Action.Submit` payload (button `data` merged with input + /// values). Present on a card-button click; absent on normal messages. + #[serde(default)] + pub value: Option, +} + +// --------------------------------------------------------------------------- +// Activity → InboundMessage normalization +// --------------------------------------------------------------------------- + +/// Remove all `` mention spans from `text` and collapse any +/// resulting runs of whitespace into single spaces. +/// +/// Handles both plain `` and attributed forms such as ``. +fn strip_at_mentions(text: &str) -> String { + // Iteratively remove ... tags (non-greedy inner match). + // We match the open tag by finding the "" so that attributes like id="0" are consumed. + let mut result = String::with_capacity(text.len()); + let mut remaining = text; + while let Some(start) = remaining.find("' or a space/tab (attribute), + // to avoid accidentally matching e.g. "" etc. + let after_prefix = &remaining[start + "') | Some(' ') | Some('\t') | Some('\r') | Some('\n') + ) { + // Not a real tag — emit up to and including "' of the open tag. + let open_tag_end = match after_prefix.find('>') { + Some(i) => i, + None => { + // Malformed — no closing '>'; keep the rest verbatim. + result.push_str(remaining); + remaining = ""; + break; + } + }; + // Skip past the entire open tag (e.g. ``). + let after_open = &after_prefix[open_tag_end + 1..]; + if let Some(end) = after_open.find("") { + remaining = &after_open[end + "".len()..]; + } else { + // Malformed — no closing tag; keep the rest verbatim. + result.push_str(remaining); + remaining = ""; + break; + } + } + result.push_str(remaining); + + // Collapse runs of whitespace (spaces, tabs, newlines) to a single space + // and trim the result. + result.split_whitespace().collect::>().join(" ") +} + +/// Determine whether the bot was @mentioned in `activity`. +/// +/// Priority: +/// 1. Check `entities` for a `"mention"` entry whose `mentioned.id` equals +/// `activity.recipient.id`. +/// 2. Fall back to presence of any `` tag in the raw text. +fn bot_was_mentioned(activity: &Activity) -> bool { + let bot_id = &activity.recipient.id; + + if let Some(entities) = &activity.entities { + for entity in entities { + let is_mention = entity + .get("type") + .and_then(|v| v.as_str()) + .map(|t| t.eq_ignore_ascii_case("mention")) + .unwrap_or(false); + + if is_mention { + let mentioned_id = entity + .pointer("/mentioned/id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if mentioned_id == bot_id { + return true; + } + } + } + } + + // Fallback: presence of an tag (with or without attributes) in text + // implies a mention. + activity + .text + .as_deref() + .map(|t| t.contains(", +) -> crate::MessageContent { + let action_id = value + .get("action_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let label = value + .get("label") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + // Any other string-valued fields are submitted input values. + let values: Vec = value + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "action_id" && k.as_str() != "label") + .filter_map(|(_, v)| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + crate::MessageContent::Interaction { + action_id, + block_id: None, + values, + label, + message_ts, + } +} + +/// Convert a Bot Framework [`Activity`] to a project-standard [`InboundMessage`]. +/// +/// Returns `None` for any activity whose `type` is not `"message"` — v1 only +/// handles conversational message activities. +/// +/// # Metadata keys set +/// +/// | Key | Description | +/// |----------------------------|--------------------------------------------------| +/// | `"message_id"` | Bot Connector activity `id` | +/// | `"teams_service_url"` | `serviceUrl` — needed by the outbound adapter | +/// | `"teams_conversation_type"`| `conversationType` when present | +/// | `"teams_reply_to_id"` | `replyToId` when present | +/// | `"teams_mentioned"` | `"true"` / `"false"` — whether the bot was @mentioned | +pub fn activity_to_inbound( + activity: &Activity, + runtime_key: &str, + media_bot_token: Option<&str>, +) -> Option { + if !activity.activity_type.eq_ignore_ascii_case("message") { + return None; + } + + let raw_text = activity.text.as_deref().unwrap_or("").trim().to_string(); + let clean_text = strip_at_mentions(&raw_text); + let mentioned = bot_was_mentioned(activity); + + // Build the conversation_id. + let base_conversation_id = format!("teams:{}", activity.conversation.id); + let conversation_id = crate::messaging::apply_runtime_adapter_to_conversation_id( + runtime_key, + base_conversation_id, + ); + + // Assemble metadata. + let mut metadata: HashMap = HashMap::new(); + + metadata.insert( + crate::metadata_keys::MESSAGE_ID.to_string(), + serde_json::json!(activity.id), + ); + metadata.insert( + "teams_service_url".to_string(), + serde_json::json!(activity.service_url), + ); + if let Some(conv_type) = &activity.conversation.conversation_type { + metadata.insert( + "teams_conversation_type".to_string(), + serde_json::json!(conv_type), + ); + } + if let Some(reply_to) = &activity.reply_to_id { + metadata.insert("teams_reply_to_id".to_string(), serde_json::json!(reply_to)); + } + metadata.insert( + "teams_mentioned".to_string(), + serde_json::json!(if mentioned { "true" } else { "false" }), + ); + + let formatted_author = if activity.from.name.is_empty() { + None + } else { + Some(activity.from.name.clone()) + }; + + // A card-button click (Action.Submit) arrives as a message Activity with a + // non-empty `value` object — surface it as an Interaction (checked before + // text/media; a Submit carries no text/attachments). + let content = if let Some(value) = activity + .value + .as_ref() + .filter(|v| v.as_object().is_some_and(|o| !o.is_empty())) + { + let message_ts = activity.reply_to_id.clone().or_else(|| { + if activity.id.is_empty() { + None + } else { + Some(activity.id.clone()) + } + }); + value_to_interaction(value, message_ts) + } else { + // Build media attachments from inbound file/image attachments. + let media: Vec = activity + .attachments + .as_deref() + .unwrap_or(&[]) + .iter() + .filter_map(|att| attachment_to_media(att, media_bot_token)) + .collect(); + + if media.is_empty() { + crate::MessageContent::Text(clean_text) + } else { + crate::MessageContent::Media { + text: if clean_text.is_empty() { + None + } else { + Some(clean_text) + }, + attachments: media, + } + } + }; + + Some(crate::InboundMessage { + id: activity.id.clone(), + source: "teams".to_string(), + adapter: Some(runtime_key.to_string()), + conversation_id, + sender_id: activity.from.id.clone(), + agent_id: None, + content, + timestamp: chrono::Utc::now(), + metadata, + formatted_author, + }) +} + +/// Map one Bot Framework attachment to a `crate::Attachment`, or `None` if it +/// is not downloadable media (e.g. a card). +fn attachment_to_media( + att: &TeamsAttachment, + media_bot_token: Option<&str>, +) -> Option { + let ct = att.content_type.as_str(); + + // Skip card attachments — not media. + if ct.starts_with("application/vnd.microsoft.card.") { + return None; + } + + // Uploaded file: anonymous downloadUrl, no auth needed. + if ct == "application/vnd.microsoft.teams.file.download.info" { + let url = att.content.get("downloadUrl")?.as_str()?.to_string(); + // NOTE: for uploaded files `mime_type` carries the Teams `fileType` + // (a bare extension like "pdf"), not a real `type/subtype` MIME — Bot + // Framework's file.download.info exposes no media type. Inline images + // use the real Content-Type. Consumers must not assume `type/subtype`. + let mime_type = att + .content + .get("fileType") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + return Some(crate::Attachment { + filename: att.name.clone().unwrap_or_else(|| "file".into()), + mime_type, + url, + size_bytes: None, + auth_header: None, + pre_saved_id: None, + }); + } + + // Inline content (e.g. an image): contentUrl requires the bot's bearer. + // SECURITY (I2): the download layer forwards `auth_header` to the URL host + // on the first hop (channel_attachments.rs:104-128). `contentUrl` comes + // from inbound JSON, so only attach the Bot Connector bearer when the host + // is on the same allowlist as outbound serviceUrls (Teams inline images + // are served from *.trafficmanager.net). Never leak the credential to an + // attacker-named host — attach the URL with no auth instead. + let url = att.content_url.clone()?; + let auth_header = match media_bot_token { + Some(t) if is_allowed_service_url(&url) => Some(format!("Bearer {t}")), + _ => None, + }; + Some(crate::Attachment { + filename: att.name.clone().unwrap_or_else(|| "attachment".into()), + mime_type: ct.to_string(), + url, + size_bytes: None, + auth_header, + pre_saved_id: None, + }) +} + +// --------------------------------------------------------------------------- +// SSRF guard +// --------------------------------------------------------------------------- + +/// Return `true` iff `url` is a safe Bot Framework serviceUrl. +/// +/// Rules: +/// - Must parse as a valid URL. +/// - Scheme must be `https` (case-insensitive). +/// - Host must end with `.botframework.com` or `.trafficmanager.net`. +/// +/// This guards against attacker-supplied serviceUrl values that could be used +/// to exfiltrate the Bot Connector bearer token. +fn is_allowed_service_url(url: &str) -> bool { + let Ok(parsed) = Url::parse(url) else { + return false; + }; + if parsed.scheme() != "https" { + return false; + } + let host = match parsed.host_str() { + Some(h) => h.to_lowercase(), + None => return false, + }; + host.ends_with(".botframework.com") || host.ends_with(".trafficmanager.net") +} + +/// Build the Bot Connector "send activity" URL for a conversation. +fn activities_url(service_url: &str, bare_conv_id: &str) -> String { + format!( + "{}/v3/conversations/{}/activities", + service_url.trim_end_matches('/'), + bare_conv_id, + ) +} + +/// Convert a `crate::Card` into an Adaptive Card `content` object. +/// +/// Faithful subset mapping (Teams mirrors the Discord `cards` payload, not +/// Slack `blocks`). Gaps with no Adaptive Card equivalent are intentionally +/// dropped: `color` (no RGB in Adaptive Cards — only semantic container +/// styles) and `CardField.inline` (no per-fact layout flag); author icon/url +/// are omitted for v2a simplicity. +fn card_to_adaptive(card: &crate::Card) -> serde_json::Value { + let mut body: Vec = Vec::new(); + + if let Some(author) = &card.author { + if !author.name.trim().is_empty() { + body.push(serde_json::json!({ + "type": "TextBlock", "text": author.name, "weight": "Bolder", + "isSubtle": true, "wrap": true, "spacing": "None" + })); + } + } + + // Title (linked if a url is present); a bare url with no title still links. + let title_text = match (&card.title, &card.url) { + (Some(t), Some(u)) => Some(format!("[{t}]({u})")), + (Some(t), None) => Some(t.clone()), + (None, Some(u)) => Some(format!("[{u}]({u})")), + (None, None) => None, + }; + if let Some(text) = title_text { + body.push(serde_json::json!({ + "type": "TextBlock", "text": text, "weight": "Bolder", + "size": "Large", "wrap": true + })); + } + + if let Some(desc) = &card.description { + body.push(serde_json::json!({ "type": "TextBlock", "text": desc, "wrap": true })); + } + + if let Some(image) = &card.image { + body.push(serde_json::json!({ "type": "Image", "url": image.url, "size": "Stretch" })); + } + if let Some(thumb) = &card.thumbnail { + body.push(serde_json::json!({ "type": "Image", "url": thumb.url, "size": "Small" })); + } + + if !card.fields.is_empty() { + let facts: Vec = card + .fields + .iter() + .map(|f| serde_json::json!({ "title": f.name, "value": f.value })) + .collect(); + body.push(serde_json::json!({ "type": "FactSet", "facts": facts })); + } + + // Footer + timestamp collapse into one subtle line. + let footer_text = match ( + card.footer.as_ref().map(|f| f.text.as_str()), + &card.timestamp, + ) { + (Some(f), Some(ts)) => Some(format!("{f} • {ts}")), + (Some(f), None) => Some(f.to_string()), + (None, Some(ts)) => Some(ts.clone()), + (None, None) => None, + }; + if let Some(text) = footer_text { + body.push(serde_json::json!({ + "type": "TextBlock", "text": text, "isSubtle": true, + "size": "Small", "spacing": "Small", "wrap": true + })); + } + + serde_json::json!({ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": body + }) +} + +/// Wrap each card as a Bot Framework Adaptive Card attachment. +fn cards_to_attachments(cards: &[crate::Card]) -> Vec { + cards + .iter() + .map(|card| { + serde_json::json!({ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": card_to_adaptive(card) + }) + }) + .collect() +} + +/// Convert a generic `Button` to an Adaptive Card action. +/// +/// A button with a `url` becomes `Action.OpenUrl`; otherwise `Action.Submit` +/// whose `data` (echoed back by Teams as the inbound `value`) carries the +/// correlation `action_id` (the button's `custom_id`, or its label as a +/// fallback) plus the human `label`. `ButtonStyle` has no portable Adaptive +/// Card equivalent and is intentionally dropped. +fn button_to_action(btn: &crate::Button) -> serde_json::Value { + if let Some(url) = &btn.url { + return serde_json::json!({ + "type": "Action.OpenUrl", + "title": btn.label, + "url": url, + }); + } + let action_id = btn.custom_id.clone().unwrap_or_else(|| btn.label.clone()); + serde_json::json!({ + "type": "Action.Submit", + "title": btn.label, + "data": { "action_id": action_id, "label": btn.label }, + }) +} + +/// Flatten `interactive_elements` into Adaptive Card actions. +/// +/// Renders `Buttons`; `Select` (Adaptive Card `Input.ChoiceSet`) is deferred +/// (see the v2b plan Scope section) and contributes no actions yet. +fn interactive_elements_to_actions(elems: &[crate::InteractiveElements]) -> Vec { + let mut actions = Vec::new(); + for elem in elems { + match elem { + crate::InteractiveElements::Buttons { buttons } => { + actions.extend(buttons.iter().map(button_to_action)); + } + // TODO(v2b.select): render SelectMenu as an Input.ChoiceSet + a + // single Action.Submit once validated against a real Teams client. + crate::InteractiveElements::Select { .. } => {} + } + } + actions +} + +/// Build a standalone Adaptive Card attachment carrying `text` (if any) and +/// the given `actions`. Appended after content cards so buttons render below. +fn actions_card_attachment(text: &str, actions: Vec) -> serde_json::Value { + let mut body: Vec = Vec::new(); + if !text.is_empty() { + body.push(serde_json::json!({ "type": "TextBlock", "text": text, "wrap": true })); + } + serde_json::json!({ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": body, + "actions": actions + } + }) +} + +/// Build the outbound `message` activity body — the exact seam `respond` uses. +/// `attachments` and `reply_to` keys are only present when non-empty/`Some`. +fn build_message_body( + text: &str, + attachments: &[serde_json::Value], + reply_to: Option<&str>, +) -> serde_json::Value { + let mut body = serde_json::json!({ "type": "message", "text": text }); + if !attachments.is_empty() { + body["attachments"] = serde_json::json!(attachments); + } + if let Some(reply_to) = reply_to { + body["replyToId"] = serde_json::json!(reply_to); + } + body +} + +/// A bare Bot Framework typing activity (auto-expires after a few seconds). +fn typing_activity_body() -> serde_json::Value { + serde_json::json!({ "type": "typing" }) +} + +/// Strip the `":"` prefix from a routing key to recover the bare +/// Microsoft conversation id. Inner colons in the MS id are preserved. +fn strip_runtime_prefix<'a>(routing_key: &'a str, runtime_key: &str) -> &'a str { + routing_key + .strip_prefix(&format!("{runtime_key}:")) + .unwrap_or(routing_key) +} + +/// POST a fully-formed activity body to the Bot Connector. +/// +/// `service_url` MUST already be SSRF-validated by the caller +/// (`resolve_service_url`). This is a free fn so the typing-refresh task can +/// call it from a `'static` context with cloned `Arc`s. +async fn post_activity( + http: &Client, + token: &TeamsTokenProvider, + service_url: &str, + bare_conv_id: &str, + body: &serde_json::Value, +) -> crate::Result<()> { + let url = activities_url(service_url, bare_conv_id); + let bearer = token.bearer().await.map_err(mark_classified_broadcast)?; + let resp = http + .post(&url) + .bearer_auth(&bearer) + .json(body) + .send() + .await + .map_err(|e| mark_classified_broadcast(anyhow::anyhow!("teams send HTTP error: {e}")))?; + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp + .text() + .await + .unwrap_or_else(|_| "".to_owned()); + return Err(mark_classified_broadcast(anyhow::anyhow!( + "teams send: Bot Connector returned {status}: {body_text}" + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Sidecar persistence helpers +// --------------------------------------------------------------------------- + +/// Persist `map` as JSON to `path` atomically (write-tmp → rename). +/// Errors are silently swallowed (log-only) to avoid disrupting normal flow. +fn save_service_urls(map: &HashMap, path: &std::path::Path) { + let tmp = path.with_extension("json.tmp"); + let Ok(json) = serde_json::to_string(map) else { + tracing::warn!(?path, "teams sidecar: failed to serialise service_urls"); + return; + }; + if std::fs::write(&tmp, &json).is_ok() { + if let Err(e) = std::fs::rename(&tmp, path) { + tracing::warn!(%e, ?path, "teams sidecar: rename failed"); + } + } else { + tracing::warn!(?path, "teams sidecar: write to tmp file failed"); + } +} + +// --------------------------------------------------------------------------- +// TeamsAdapter — inbound HTTP server +// --------------------------------------------------------------------------- + +use arc_swap::ArcSwap; +use axum::Router; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use tokio::sync::mpsc; + +use crate::config::TeamsPermissions; +use crate::messaging::traits::{ + InboundStream, Messaging, ensure_supported_broadcast_response, mark_classified_broadcast, + mark_permanent_broadcast, +}; +use crate::{InboundMessage, OutboundResponse, StatusUpdate}; + +/// Shared state injected into axum handlers. +#[derive(Clone)] +struct TeamsHandlerState { + inbound_tx: mpsc::Sender, + /// Used to mint the bot bearer for downloading inline-image attachments. + token: Arc, + jwks: Arc, + app_id: String, + service_urls: Arc>>, + permissions: Arc>, + runtime_key: String, + sidecar_path: Option, +} + +/// Microsoft Teams Bot Framework adapter. +pub struct TeamsAdapter { + /// Runtime key (adapter name), e.g. `"teams"` or `"teams:prod"`. + runtime_key: String, + app_id: String, + #[allow(dead_code)] + tenant_id: String, + token: Arc, + jwks: Arc, + port: u16, + bind: String, + /// `conversation_id → serviceUrl` — populated on each inbound Activity so + /// the outbound adapter knows where to send replies. + service_urls: Arc>>, + /// Lazily populated by `start()`; stored so handlers can send inbound + /// messages without holding a lock across await points. + inbound_tx: Arc>>>, + permissions: Arc>, + /// HTTP client for outbound Bot Connector requests. + http_client: Client, + /// Optional path for sidecar persistence of service_urls. + sidecar_path: Option, + /// Active typing-refresh tasks, keyed by `conversation_id`. Each loops + /// re-sending a `typing` activity until aborted by `stop_typing`. + typing_tasks: Arc>>>, +} + +impl TeamsAdapter { + /// Construct a new `TeamsAdapter` from discrete credentials. + /// + /// `permissions` should be pre-built via `TeamsPermissions::from_config` / + /// `from_instance_config` and wrapped in `Arc>` so the config + /// watcher can hot-reload it without restarting the listener. + pub fn new( + runtime_key: impl Into, + app_id: impl Into, + client_secret: impl Into, + tenant_id: impl Into, + port: u16, + bind: impl Into, + permissions: Arc>, + ) -> anyhow::Result { + let tenant_id = tenant_id.into(); + let app_id = app_id.into(); + let token = Arc::new(TeamsTokenProvider::new( + tenant_id.clone(), + app_id.clone(), + client_secret.into(), + )?); + let jwks = Arc::new(JwksCache::new()?); + let http_client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .context("failed to build HTTP client for Teams outbound")?; + + Ok(Self { + runtime_key: runtime_key.into(), + app_id, + tenant_id, + token, + jwks, + port, + bind: bind.into(), + service_urls: Arc::new(Mutex::new(HashMap::new())), + inbound_tx: Arc::new(RwLock::new(None)), + permissions, + http_client, + sidecar_path: None, + typing_tasks: Arc::new(RwLock::new(HashMap::new())), + }) + } + + /// Set the sidecar persistence path for `service_urls`. + /// + /// When set, `service_urls` is loaded from this path on `start()` and + /// persisted atomically after each inbound capture or outbound send. + pub fn with_sidecar_path(mut self, path: PathBuf) -> Self { + self.sidecar_path = Some(path); + self + } + + /// Resolve and SSRF-validate the serviceUrl for an outbound send. + /// + /// Prefers the `inline` hint (the inbound activity's captured serviceUrl); + /// otherwise looks up `service_urls[routing_key]`. Returns a permanent + /// error if missing or blocked by the SSRF guard. + async fn resolve_service_url( + &self, + routing_key: &str, + inline: Option<&str>, + ) -> crate::Result { + let service_url = match inline { + Some(u) => u.to_string(), + None => { + let urls = self.service_urls.lock().await; + match urls.get(routing_key).cloned() { + Some(u) => u, + None => { + return Err(mark_permanent_broadcast(anyhow::anyhow!( + "teams send: no serviceUrl for routing key {routing_key}" + ))); + } + } + } + }; + if !is_allowed_service_url(&service_url) { + return Err(mark_permanent_broadcast(anyhow::anyhow!( + "teams send: serviceUrl blocked by SSRF guard: {service_url}" + ))); + } + Ok(service_url) + } + + /// Resolve the serviceUrl, send `body`, and persist the sidecar. + async fn send_activity( + &self, + routing_key: &str, + inline: Option<&str>, + body: serde_json::Value, + ) -> crate::Result<()> { + let service_url = self.resolve_service_url(routing_key, inline).await?; + let bare_conv_id = strip_runtime_prefix(routing_key, &self.runtime_key); + post_activity( + &self.http_client, + &self.token, + &service_url, + bare_conv_id, + &body, + ) + .await?; + if let Some(ref path) = self.sidecar_path { + let urls = self.service_urls.lock().await; + save_service_urls(&urls, path); + } + Ok(()) + } + + /// Abort and drop the typing-refresh task for a conversation, if any. + async fn stop_typing(&self, conversation_id: &str) { + if let Some(handle) = self.typing_tasks.write().await.remove(conversation_id) { + handle.abort(); + } + } +} + +/// Build a `TeamsAdapter` with the sidecar path set to +/// `/teams_service_urls.json`. +/// +/// This is the canonical constructor used by both the daemon startup path and +/// the config-watcher reload path so the two never drift apart. +pub fn build_teams_adapter( + runtime_key: impl Into, + app_id: impl Into, + client_secret: impl Into, + tenant_id: impl Into, + port: u16, + bind: impl Into, + permissions: std::sync::Arc>, + instance_dir: &std::path::Path, +) -> anyhow::Result { + Ok(TeamsAdapter::new( + runtime_key, + app_id, + client_secret, + tenant_id, + port, + bind, + permissions, + )? + .with_sidecar_path(instance_dir.join("teams_service_urls.json"))) +} + +impl Messaging for TeamsAdapter { + fn name(&self) -> &str { + &self.runtime_key + } + + async fn start(&self) -> crate::Result { + // Load sidecar on startup if configured. + if let Some(ref path) = self.sidecar_path { + match std::fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str::>(&contents) { + Ok(loaded) => { + *self.service_urls.lock().await = loaded; + tracing::info!(?path, "teams sidecar: loaded service_urls"); + } + Err(e) => { + tracing::warn!(%e, ?path, "teams sidecar: failed to parse service_urls JSON"); + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Expected on first startup — not an error. + } + Err(e) => { + tracing::warn!(%e, ?path, "teams sidecar: failed to read service_urls file"); + } + } + } + + let (inbound_tx, inbound_rx) = mpsc::channel::(256); + *self.inbound_tx.write().await = Some(inbound_tx.clone()); + + let state = TeamsHandlerState { + inbound_tx, + token: self.token.clone(), + jwks: self.jwks.clone(), + app_id: self.app_id.clone(), + service_urls: self.service_urls.clone(), + permissions: self.permissions.clone(), + runtime_key: self.runtime_key.clone(), + sidecar_path: self.sidecar_path.clone(), + }; + + let app = Router::new() + .route("/api/messages", post(handle_messages)) + .route("/health", get(handle_health)) + .with_state(state); + + let bind = if self.bind.contains(':') { + format!("[{}]:{}", self.bind, self.port) + } else { + format!("{}:{}", self.bind, self.port) + }; + + let listener = tokio::net::TcpListener::bind(&bind) + .await + .with_context(|| format!("failed to bind Teams webhook server to {bind}"))?; + + tracing::info!(%bind, "Teams webhook server listening"); + + tokio::spawn(async move { + if let Err(error) = axum::serve(listener, app).await { + tracing::error!(%error, "Teams webhook server exited with error"); + } + }); + + let stream = tokio_stream::wrappers::ReceiverStream::new(inbound_rx); + Ok(Box::pin(stream)) + } + + async fn respond( + &self, + message: &InboundMessage, + response: OutboundResponse, + ) -> crate::Result<()> { + // Extract text + any cards (or return Ok(()) for unsupported variants). + let (text, attachments) = match response { + OutboundResponse::Text(t) => (t, Vec::new()), + // Teams threads via the conversation id itself (channel messages + // carry ...@thread.tacv2;messageid=), and respond posts back + // to that id forwarding reply_to_id — so a ThreadReply already + // lands in the originating thread. `thread_name` (a Discord-style + // named new thread) has no Bot Framework equivalent and is dropped. + OutboundResponse::ThreadReply { text, .. } => (text, Vec::new()), + OutboundResponse::Ephemeral { text, .. } => (text, Vec::new()), + OutboundResponse::ScheduledMessage { text, .. } => (text, Vec::new()), + // Teams consumes `cards` + `interactive_elements`; `blocks`/`poll` + // are ignored (text remains the fallback when there are none). + OutboundResponse::RichMessage { + text, + cards, + interactive_elements, + .. + } => { + let mut atts = if cards.is_empty() { + Vec::new() + } else { + cards_to_attachments(&cards) + }; + let actions = interactive_elements_to_actions(&interactive_elements); + if !actions.is_empty() { + atts.push(actions_card_attachment(&text, actions)); + } + (text, atts) + } + // Reactions are not in the Bot Connector REST API (they need + // Microsoft Graph + a separate auth scope) — deferred, see roadmap. + OutboundResponse::Reaction(_) + | OutboundResponse::RemoveReaction(_) + | OutboundResponse::Status(_) + | OutboundResponse::StreamStart + | OutboundResponse::StreamChunk(_) + | OutboundResponse::StreamEnd + | OutboundResponse::File { .. } => return Ok(()), + }; + + let inline = message + .metadata + .get("teams_service_url") + .and_then(|v| v.as_str()); + let reply_to_id = message + .metadata + .get("teams_reply_to_id") + .and_then(|v| v.as_str()); + + let body = build_message_body(&text, &attachments, reply_to_id); + self.send_activity(&message.conversation_id, inline, body) + .await + } + + async fn send_status( + &self, + message: &InboundMessage, + status: StatusUpdate, + ) -> crate::Result<()> { + let conversation_id = message.conversation_id.clone(); + match status { + StatusUpdate::Thinking => { + let inline = message + .metadata + .get("teams_service_url") + .and_then(|v| v.as_str()); + // Resolve once up front; a miss/blocked URL is non-fatal for typing. + let service_url = match self.resolve_service_url(&conversation_id, inline).await { + Ok(u) => u, + Err(error) => { + tracing::debug!(%error, "teams typing: no serviceUrl; skipping"); + return Ok(()); + } + }; + let bare_conv_id = + strip_runtime_prefix(&conversation_id, &self.runtime_key).to_string(); + let http = self.http_client.clone(); + let token = self.token.clone(); + + let handle = tokio::spawn(async move { + let body = typing_activity_body(); + loop { + if let Err(error) = + post_activity(&http, &token, &service_url, &bare_conv_id, &body).await + { + tracing::debug!(%error, "teams typing send failed; stopping loop"); + break; + } + // Teams typing expires after a few seconds — refresh. + tokio::time::sleep(Duration::from_secs(2)).await; + } + }); + + // Replace any prior task for this conversation. + if let Some(old) = self + .typing_tasks + .write() + .await + .insert(conversation_id, handle) + { + old.abort(); + } + } + // Teams has no richer status surface in v2a — any non-Thinking + // status clears typing. + _ => self.stop_typing(&conversation_id).await, + } + Ok(()) + } + + async fn broadcast(&self, target: &str, response: OutboundResponse) -> crate::Result<()> { + // Gate: only Text is supported for proactive broadcast. + fn is_supported(r: &OutboundResponse) -> bool { + matches!(r, OutboundResponse::Text(_)) + } + ensure_supported_broadcast_response("teams", &response, is_supported)?; + + let OutboundResponse::Text(text) = response else { + unreachable!() + }; + + let body = serde_json::json!({ "type": "message", "text": text }); + self.send_activity(target, None, body).await + } + + async fn health_check(&self) -> crate::Result<()> { + self.token + .bearer() + .await + .map(|_| ()) + .map_err(crate::error::Error::Other) + } +} + +// --------------------------------------------------------------------------- +// Axum handlers +// --------------------------------------------------------------------------- + +/// POST /api/messages — the Bot Framework inbound message endpoint. +/// +/// Security flow: +/// 1. Validate `Authorization: Bearer ` via Azure Bot Service JWKS → 401 on failure. +/// 2. Parse JSON body as `Activity` → 400 on failure. +/// 3. Capture `serviceUrl` into the `service_urls` map. +/// 4. Normalize to `InboundMessage` (returns `None` for non-message activities) → 200, no dispatch. +/// 5. Permission check (DM: enforce `dm_allowed_users`; channel: enforce `channel_filter` if set) +/// → silently drop (200 OK, no dispatch) on deny. +/// 6. Send to inbound channel; return 200. +async fn handle_messages( + headers: HeaderMap, + State(state): State, + body: axum::body::Bytes, +) -> Result { + // --- Step 1: JWT auth --- + let auth_header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if let Err(err) = validate_inbound_jwt(auth_header, &state.app_id, &state.jwks).await { + tracing::warn!(%err, "Teams inbound JWT validation failed — returning 401"); + return Err((StatusCode::UNAUTHORIZED, "unauthorized")); + } + + // --- Step 2: Parse body --- + let activity: Activity = match serde_json::from_slice(&body) { + Ok(a) => a, + Err(err) => { + tracing::warn!(%err, "Teams inbound activity parse failed — returning 400"); + return Err((StatusCode::BAD_REQUEST, "bad request")); + } + }; + + // --- Step 3: Capture serviceUrl (keyed by rewritten conversation_id) --- + { + let base = format!("teams:{}", activity.conversation.id); + let conv_key = + crate::messaging::apply_runtime_adapter_to_conversation_id(&state.runtime_key, base); + let mut urls = state.service_urls.lock().await; + urls.insert(conv_key, activity.service_url.clone()); + // Persist sidecar if configured. + if let Some(ref path) = state.sidecar_path { + save_service_urls(&urls, path); + } + } + + // --- Step 4: Permission check --- + let conversation_type = activity.conversation.conversation_type.as_deref(); + let channel_id = &activity.conversation.id; + let sender_id = &activity.from.id; + + let perms = state.permissions.load(); + if !perms.is_allowed(conversation_type, sender_id, channel_id) { + tracing::debug!( + sender_id, + ?conversation_type, + channel_id, + "Teams inbound message dropped by permission filter" + ); + // Return 200 so Bot Framework doesn't retry. + return Ok(StatusCode::OK); + } + + // --- Step 5: Normalize to InboundMessage --- + // Inline-image attachments need the bot's bearer to download; fetch it + // best-effort only when the activity carries attachments. + let media_bot_token = if activity.attachments.as_ref().is_some_and(|a| !a.is_empty()) { + state.token.bearer().await.ok() + } else { + None + }; + let Some(msg) = activity_to_inbound(&activity, &state.runtime_key, media_bot_token.as_deref()) + else { + // Non-message activity (typing, conversationUpdate, etc.) — ack and ignore. + return Ok(StatusCode::OK); + }; + + // --- Step 6: Dispatch --- + if state.inbound_tx.send(msg).await.is_err() { + tracing::warn!("Teams inbound channel closed; dropping message"); + } + + Ok(StatusCode::OK) +} + +async fn handle_health() -> StatusCode { + StatusCode::OK +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use serde_json::json; + use std::time::{Duration, Instant, UNIX_EPOCH}; + + const LEEWAY: Duration = REFRESH_LEEWAY; + + // ----------------------------------------------------------------------- + // JWT test helpers + // ----------------------------------------------------------------------- + + /// RSA-2048 private key (PKCS#8 PEM) generated for tests only. + /// This key is NOT used in production. + const TEST_RSA_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDH2tXs+GABUCDT +URh3/iL7/5zZP3yXNThRzAFNiJiJ7Zt/RUE9SSLhD6UuoHsOAMeyKhP7AoodMinG +npsQXEV9R0JoCH2jISo8xV/BKRNLxdKCcOZFpye7e9mNnMvOETO2KEhUEgAcySDu +kMBn8WTyFUYrQNj/+ih0W4UbEaMVnJiiEWmq+yj0xo6DYedxEYIVrGVB+RC94RTo +fPE/UzRL1fnhid4X8RaG9vVhaSWUDX7b6LCsI73KB9yupiqMMBtW2hRU0L9UKE09 +aBoahn5EoCHQnb/3/cVbBd4MpkuzGbLmQV6Pf3SsL8/yESgewGbLhZvp1lz4vDI3 +4iuQ81zvAgMBAAECggEAGRxvdbNtiKyzOyn825LUfXpMEGXwNyWKOojZ/w5zMB1p +RNAEVvl6BvJKzHWAkK1bahDsasUSaoGziw/BpwgY+Rk7iEvM0XLo1jLsiZ4qHQKx +pQ8fd8/9Z4qztp3lY7J4n2InWFzco8FHwIHykvzbNKmko+mlemBJtfkL2+9W4O+P +r87SHfStVoFzOQo4hv8pTgCR6+ZFTcgEtCDvB3FM8sgO6+hYJR4TXeSL3lbjk1aB +VdNEgJjSzM7dcSB+4HUE7cnXS1MtkwdQNGgz995j0cQbm4LsdV1F87Hcm6M3QorL +qOqTl67hbldbTRYtotXazWOt/Qapujg/p1it8+kA6QKBgQDw4yBzJQC3TnHisdsf +twemuvCxFMdsZs1uxGPgWJjkv4VutkCKeu5KFWK3O92rBydDXB/IweON5AR7iqmi +0NhtmK1xFxBagSeTvz/kPsHkyzsm/lbZM8LoSCCjtQ91UXlh61XGgDsnAQIYsiNr +006ev9y7vsxqFhOjnQaOmAwmGQKBgQDUZLDEYltmh6MhyO6lPhj044MMCMCA+aEu +Shl/EWb/Eoo48MTrDDFet4/9tAwoVEjHO46ymiP6MEtne6ChwSjI75jW39lUgwPb +d0YXK/M+BZtXog6xqHXkJcGrJcZoBiFj8AwkwlK1n1cjGSVokodyK8JjQ0iJu0EV +C4tJQ1ysRwKBgBvuSgHv5XBbwSrG8qBvyYxUmrn9rc3s8Z8JWIdX3oqPhno62ar0 +7BJc/nA+mcpN7wiJcwoFKUx3humIP3kofB/hFyNIyFWmKh+gilj9yd+sjPRNg2Z1 +8QCb9GTnBp7Uzp1C+1Qj5Df2jvasGR1UiAYyOvbt/afDXY2YFH2ONcJpAoGBAKji +p+yAiU0t7Xmf3KNojU+s2TdofioQVSoJodx4af3JMD+2s95zA47dR5Hk6QXofzZt +FTrPdmwqmsrecwwsG9IrMs0pkhaxVw/b98/VEsXuj2dPZX+/BH81xpngn7N3rHVb +G0zfeAUTfqZaCHTujuUqBpgHmFZsn4OsekT3W2lhAoGAbnB7k+gt5cWiX4lgn28F +YtHcWbK536eBIV1/zZ2u9Yx5qkfUmIVLLLQU2pFKO804Jkq1XsguHgYIsoUoz4LF +yVH0ymEQeYGdqgh4Q5k1ckpY5pHeJuEr6r3snx4gMZsH40jk+dVf58Ab30MOE1p3 +XLeQgmAl46RoBo1wHm3lfDc= +-----END PRIVATE KEY-----"; + + /// RSA-2048 public key (SPKI PEM) matching TEST_RSA_PRIVATE_KEY_PEM. + const TEST_RSA_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9rV7PhgAVAg01EYd/4i ++/+c2T98lzU4UcwBTYiYie2bf0VBPUki4Q+lLqB7DgDHsioT+wKKHTIpxp6bEFxF +fUdCaAh9oyEqPMVfwSkTS8XSgnDmRacnu3vZjZzLzhEztihIVBIAHMkg7pDAZ/Fk +8hVGK0DY//oodFuFGxGjFZyYohFpqvso9MaOg2HncRGCFaxlQfkQveEU6HzxP1M0 +S9X54YneF/EWhvb1YWkllA1+2+iwrCO9ygfcrqYqjDAbVtoUVNC/VChNPWgaGoZ+ +RKAh0J2/9/3FWwXeDKZLsxmy5kFej390rC/P8hEoHsBmy4Wb6dZc+LwyN+IrkPNc +7wIDAQAB +-----END PUBLIC KEY-----"; + + /// A second RSA keypair (different from TEST_RSA_PRIVATE_KEY_PEM), used to + /// test that a signature by the wrong key is rejected. + const TEST_RSA_WRONG_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDMf0/gMOhseRlY +7HQlJ1/T5W6p6Dp4Wnqp/X0SzNtKYcZuC78GwQx7Ukha6GxHP/STH40KlIsZTtym +QiAlTpP9sZ874F11RqVuX5Sg1b3U3IE0bohgcyzpjVwYyBMCI5uxUDf2VJnOx9JI +Hhi9AxMGz+2dq8L3FGRWTgsEPEFpx6IuxK+rXS2LxAYDIlfIDVCksFuZLlJISDAT +4FIvAOMI2C7tDpFzVbn6blns5nxduz7O/IpW6XFkfeCZbL+lDfIFhK8jo1RGM4x7 +39S4kHha/Er8sVVQKm83CHJEv+ueuGIAvFd6mp+W8CybE8Hx98nWvXgZXFAFrThl +lluC5JmtAgMBAAECggEAI9Djdj5Sotb13clyESTLh5L+NhNmlDgyli2/t2h6OtWT +magEgcQTcdDoO8XL2xHEPfVPcEwybZEOm67mruoLiOoQW74Q2E6ygDmM0DuHRy4E +kiCO0aeydMhNmkiGbcA7T0uftYy9MIZ2WautxQLyFOYbdZtE5x3i8euyyb/c7A/a +v1bG/pIfzSL2ZIA/3E0PpKL/17KbfNaIJoFBJcDd1AfwKG1zPJq7dWcyYol1nGtL +Flz+nuKD26SAjJNjtipjYkHczDoTtGM9gBfe/QULcakNpPyUetrI1ZKDqgglN27Q +zPeByY6GkTgmochYOtwf8s5LrpN2C5BXiTTNkZKRCQKBgQD3YySPHDxj2Fp4T9qQ +4l99S6ZU+fNFxr4f21ejnH5RtKH8m1WXZbk3VRjrfZOQ8OH4rAc6t4TEX3XPGQMV +iYLraoOGiw0AxiYLxYtvZvihz3xyw8Rwf8vtikiDEaRvENF0IUu2Bt75wU3G1lcV +HGvtySn7hbJPiFicpZqEVCGEyQKBgQDTnejFncArGRd0kMqGrhRJe4+k1004waj/ +edspKS2QOL7BAYj884udRzpkxyr+bhr78/9R45qnrK/J4oDAvQatqzgLj6E3mCQq +STnb4OxFzJL9laxVMeT+zFFInft1muu2rAqSYowOTsS98fIhM4u4CP+g+m7vZjiz +ERNepgqTxQKBgQCoCqNZxr9KvzrtAKkhw3NDo/BvRn22RwL8lrzYOUQg8gcalNU2 +CvYeHOLZi6qCSO3mQcyDWQeJcKKQs5fBuG/Cw85lxOxnOzG6y0wktxhqqYsKVeqI +1HZMe6M3zPMaMp1kOf24vsAVfPX8+7mZcH3rvrqSzMVLev1eIqtr+c3u6QKBgBJN +Qeh1cD1J+kFWlG15eL+yNAYpqMAT363YuB+jNBGZFsZSf6qA1b5QfrhgkVNX6nWH +8LkAWkvOH5XyRPhmYMF8YWh+j47jVZ1in+JoXYbb3oqX+0OTAR8YRJ9nKmxNbb1q +u69VXo+OOG3FEw/UCW1tOc6OWjHSQW0bOPWinp+RAoGBANlGO02t4Znm+EwB4E8g +X8MIxJ0kKgZuy+SZNWJ4J8BzH0X/C9hGSNAnVoKvsT3YpB/HHGNvo4CJv7PnEXFH +5nQOdg/gId6PsFwwKi/9e8QPM3MzUvpdgbYdk8xTDPyGpyEwDsV2kMo1WIYQchBu +vIyJeH8/89a9IXZXlMIA9KH9 +-----END PRIVATE KEY-----"; + + const APP_ID: &str = "test-app-id-12345"; + const ISSUER: &str = "https://api.botframework.com"; + + /// Build a minimal JWT with the given claims and sign with the provided key. + fn make_jwt( + iss: &str, + aud: &str, + exp_offset_secs: i64, // positive = future, negative = past + encoding_key: &EncodingKey, + ) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time before epoch") + .as_secs() as i64; + + let claims = json!({ + "iss": iss, + "aud": aud, + "exp": now + exp_offset_secs, + "nbf": now - 60, + "iat": now - 60, + }); + + let header = Header::new(Algorithm::RS256); + encode(&header, &claims, encoding_key).expect("test JWT encoding failed") + } + + fn encoding_key() -> EncodingKey { + EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY_PEM.as_bytes()) + .expect("test private key is valid") + } + + fn wrong_encoding_key() -> EncodingKey { + EncodingKey::from_rsa_pem(TEST_RSA_WRONG_PRIVATE_KEY_PEM.as_bytes()) + .expect("wrong test private key is valid") + } + + fn decoding_key() -> DecodingKey { + DecodingKey::from_rsa_pem(TEST_RSA_PUBLIC_KEY_PEM).expect("test public key is valid") + } + + // ----------------------------------------------------------------------- + // JWT validation tests (use injectable validate_token_with_key) + // ----------------------------------------------------------------------- + + /// Valid token with correct iss, aud, exp, signed by the expected key. + #[test] + fn test_jwt_valid_token() { + let token = make_jwt(ISSUER, APP_ID, 3600, &encoding_key()); + assert!( + validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()).is_ok(), + "valid token should pass validation" + ); + } + + /// Token with wrong audience → rejected. + #[test] + fn test_jwt_wrong_aud() { + let token = make_jwt(ISSUER, "wrong-aud", 3600, &encoding_key()); + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!(err.is_err(), "wrong aud should be rejected; got: {err:?}"); + } + + /// Token with wrong issuer → rejected. + #[test] + fn test_jwt_wrong_iss() { + let token = make_jwt("https://evil.example.com", APP_ID, 3600, &encoding_key()); + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!(err.is_err(), "wrong iss should be rejected; got: {err:?}"); + } + + /// Token already expired (exp in the past, beyond any leeway) → rejected. + #[test] + fn test_jwt_expired() { + // 10 minutes in the past — beyond the 5-minute leeway. + let token = make_jwt(ISSUER, APP_ID, -600, &encoding_key()); + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!( + err.is_err(), + "expired token should be rejected; got: {err:?}" + ); + } + + /// Token signed by a DIFFERENT private key → signature fails. + #[test] + fn test_jwt_wrong_signing_key() { + let token = make_jwt(ISSUER, APP_ID, 3600, &wrong_encoding_key()); + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!( + err.is_err(), + "token signed by wrong key should be rejected; got: {err:?}" + ); + } + + /// Token signed with HS256 (HMAC) → rejected because only RS256 is allowed. + #[test] + fn test_jwt_hs256_rejected() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time before epoch") + .as_secs() as i64; + + let claims = serde_json::json!({ + "iss": ISSUER, + "aud": APP_ID, + "exp": now + 3600, + }); + + let hmac_key = EncodingKey::from_secret(b"some-hmac-secret"); + let header = Header::new(Algorithm::HS256); + let token = + jsonwebtoken::encode(&header, &claims, &hmac_key).expect("HMAC token encoding failed"); + + // The decoding key is an RSA key; jsonwebtoken will reject the HS256 alg. + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!(err.is_err(), "HS256 token should be rejected; got: {err:?}"); + } + + /// Token with `aud` claim entirely absent → rejected. + /// + /// Regression test: jsonwebtoken only *validates* `aud` when the claim is + /// present. `set_required_spec_claims` must include `"aud"` so that a + /// correctly signed token that simply omits the audience is not accepted. + #[test] + fn test_jwt_missing_aud_rejected() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time before epoch") + .as_secs() as i64; + + // Intentionally omit the `aud` field. + let claims = json!({ + "iss": ISSUER, + "exp": now + 3600, + "nbf": now - 60, + "iat": now - 60, + }); + + let header = Header::new(Algorithm::RS256); + let token = encode(&header, &claims, &encoding_key()).expect("test JWT encoding failed"); + + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!( + err.is_err(), + "token missing `aud` claim should be rejected; got: {err:?}" + ); + } + + /// Token with `iss` claim entirely absent → rejected. + /// + /// Regression test: jsonwebtoken only *validates* `iss` when the claim is + /// present. `set_required_spec_claims` must include `"iss"` so that a + /// correctly signed token that omits the issuer is not accepted. + #[test] + fn test_jwt_missing_iss_rejected() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time before epoch") + .as_secs() as i64; + + // Intentionally omit the `iss` field. + let claims = json!({ + "aud": APP_ID, + "exp": now + 3600, + "nbf": now - 60, + "iat": now - 60, + }); + + let header = Header::new(Algorithm::RS256); + let token = encode(&header, &claims, &encoding_key()).expect("test JWT encoding failed"); + + let err = validate_token_with_key(&token, APP_ID, ISSUER, &decoding_key()); + assert!( + err.is_err(), + "token missing `iss` claim should be rejected; got: {err:?}" + ); + } + + // ----------------------------------------------------------------------- + // `validate_inbound_jwt` header-parsing tests (no network, no JWKS) + // ----------------------------------------------------------------------- + + /// Missing "Bearer " prefix → rejected immediately. + #[tokio::test] + async fn test_inbound_jwt_missing_bearer_prefix() { + let jwks = JwksCache::new().expect("JwksCache::new"); + let token = make_jwt(ISSUER, APP_ID, 3600, &encoding_key()); + // Pass a raw token without the "Bearer " prefix. + let err = validate_inbound_jwt(&token, APP_ID, &jwks).await; + assert!( + err.is_err(), + "missing Bearer prefix should be rejected; got: {err:?}" + ); + } + + /// Empty bearer token → rejected immediately. + #[tokio::test] + async fn test_inbound_jwt_empty_bearer() { + let jwks = JwksCache::new().expect("JwksCache::new"); + let err = validate_inbound_jwt("Bearer ", APP_ID, &jwks).await; + assert!( + err.is_err(), + "empty Bearer token should be rejected; got: {err:?}" + ); + } + + // ----------------------------------------------------------------------- + // Token-provider tests (needs_refresh) + // ----------------------------------------------------------------------- + + /// Well before expiry: token is fresh, no refresh needed. + #[test] + fn test_needs_refresh_fresh_token() { + let now = Instant::now(); + // Token expires 1 hour from now; leeway is 5 min → still fresh. + let expires_at = now + Duration::from_secs(3600); + assert!(!needs_refresh(expires_at, now, LEEWAY)); + } + + /// Exactly at the refresh boundary (now == expires_at - leeway): refresh. + #[test] + fn test_needs_refresh_at_boundary() { + let now = Instant::now(); + // Token expires exactly `leeway` seconds from now. + let expires_at = now + LEEWAY; + assert!(needs_refresh(expires_at, now, LEEWAY)); + } + + /// Inside the leeway window (expires in 2 min, leeway 5 min): refresh. + #[test] + fn test_needs_refresh_inside_leeway() { + let now = Instant::now(); + let expires_at = now + Duration::from_secs(120); // 2 min < 5 min leeway + assert!(needs_refresh(expires_at, now, LEEWAY)); + } + + /// Token already expired: must refresh. + #[test] + fn test_needs_refresh_expired() { + let now = Instant::now(); + // `expires_at` is in the past. + let expires_at = now - Duration::from_secs(1); + assert!(needs_refresh(expires_at, now, LEEWAY)); + } + + /// Token still has leeway + 1 second left: not yet time to refresh. + #[test] + fn test_needs_refresh_just_before_boundary() { + let now = Instant::now(); + // Expires in leeway + 1 s → still fresh by exactly 1 second. + let expires_at = now + LEEWAY + Duration::from_secs(1); + assert!(!needs_refresh(expires_at, now, LEEWAY)); + } + + // ----------------------------------------------------------------------- + // Activity → InboundMessage mapping tests + // ----------------------------------------------------------------------- + + /// Personal (DM) message: conversation_id, content, sender, metadata. + #[test] + fn test_activity_to_inbound_personal_dm() { + let raw = r#"{ + "type": "message", + "id": "act-001", + "text": "Hello bot!", + "from": { "id": "user-aaa", "name": "Alice Smith" }, + "conversation": { "id": "conv-dm-001", "conversationType": "personal" }, + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "recipient": { "id": "bot-bbb", "name": "MyBot" }, + "channelData": {}, + "entities": [] + }"#; + + let activity: Activity = serde_json::from_str(raw).expect("parse activity"); + let msg = activity_to_inbound(&activity, "teams", None) + .expect("should produce InboundMessage for message activity"); + + assert_eq!(msg.id, "act-001"); + assert_eq!(msg.source, "teams"); + assert_eq!(msg.adapter, Some("teams".to_string())); + assert_eq!(msg.conversation_id, "teams:conv-dm-001"); + assert_eq!(msg.sender_id, "user-aaa"); + assert_eq!(msg.formatted_author, Some("Alice Smith".to_string())); + + // Content text should be unchanged (no tags). + if let crate::MessageContent::Text(text) = &msg.content { + assert_eq!(text, "Hello bot!"); + } else { + panic!("expected Text content"); + } + + // Metadata: serviceUrl and conversationType. + assert_eq!( + msg.metadata + .get("teams_service_url") + .and_then(|v| v.as_str()), + Some("https://smba.trafficmanager.net/amer/") + ); + assert_eq!( + msg.metadata + .get("teams_conversation_type") + .and_then(|v| v.as_str()), + Some("personal") + ); + // No mention in DM (no tag and no mention entity for bot-bbb). + assert_eq!( + msg.metadata.get("teams_mentioned").and_then(|v| v.as_str()), + Some("false") + ); + // message_id metadata. + assert_eq!( + msg.metadata + .get(crate::metadata_keys::MESSAGE_ID) + .and_then(|v| v.as_str()), + Some("act-001") + ); + } + + /// Channel @mention: tag is stripped, teams_mentioned == "true". + #[test] + fn test_activity_to_inbound_channel_mention_strips_at_tag() { + let raw = r#"{ + "type": "message", + "id": "act-002", + "text": "MyBot hello world", + "from": { "id": "user-bbb", "name": "Bob Jones" }, + "conversation": { "id": "conv-ch-999", "conversationType": "channel" }, + "serviceUrl": "https://smba.trafficmanager.net/emea/", + "recipient": { "id": "bot-bbb", "name": "MyBot" }, + "channelData": {}, + "entities": [ + { + "type": "mention", + "mentioned": { "id": "bot-bbb", "name": "MyBot" }, + "text": "MyBot" + } + ] + }"#; + + let activity: Activity = serde_json::from_str(raw).expect("parse activity"); + let msg = + activity_to_inbound(&activity, "teams", None).expect("should produce InboundMessage"); + + // MyBot prefix should be stripped; remaining text trimmed. + if let crate::MessageContent::Text(text) = &msg.content { + assert_eq!(text, "hello world"); + } else { + panic!("expected Text content"); + } + + assert_eq!( + msg.metadata.get("teams_mentioned").and_then(|v| v.as_str()), + Some("true") + ); + assert_eq!(msg.conversation_id, "teams:conv-ch-999"); + } + + /// Non-message activity type returns None. + #[test] + fn test_activity_to_inbound_non_message_returns_none() { + let raw = r#"{ + "type": "conversationUpdate", + "id": "act-003", + "from": { "id": "user-ccc", "name": "Carol" }, + "conversation": { "id": "conv-xyz", "conversationType": "channel" }, + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "recipient": { "id": "bot-ddd", "name": "MyBot" }, + "channelData": {} + }"#; + + let activity: Activity = serde_json::from_str(raw).expect("parse activity"); + let result = activity_to_inbound(&activity, "teams", None); + assert!( + result.is_none(), + "conversationUpdate should produce None, got: {result:?}" + ); + } + + /// Named-instance runtime_key rewrites the conversation_id prefix. + #[test] + fn test_activity_to_inbound_named_runtime_key() { + let raw = r#"{ + "type": "message", + "id": "act-004", + "text": "ping", + "from": { "id": "user-ddd", "name": "Dave" }, + "conversation": { "id": "conv-named-001" }, + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "recipient": { "id": "bot-eee", "name": "MyBot" }, + "channelData": {} + }"#; + + let activity: Activity = serde_json::from_str(raw).expect("parse activity"); + let msg = activity_to_inbound(&activity, "teams:support", None) + .expect("should produce InboundMessage"); + + // Named adapter: runtime_key != "teams", so prefix should be rewritten. + assert_eq!(msg.conversation_id, "teams:support:conv-named-001"); + assert_eq!(msg.adapter, Some("teams:support".to_string())); + } + + // ----------------------------------------------------------------------- + // Inbound attachments → MessageContent::Media + // ----------------------------------------------------------------------- + + fn media_activity(attachments: serde_json::Value, text: &str) -> Activity { + let raw = serde_json::json!({ + "type": "message", + "id": "act-1", + "text": text, + "from": { "id": "user-1", "name": "Alice" }, + "conversation": { "id": "conv-1", "conversationType": "personal" }, + "serviceUrl": "https://smba.trafficmanager.net/emea/", + "recipient": { "id": "bot-1", "name": "Bot" }, + "attachments": attachments + }); + serde_json::from_value(raw).expect("activity parses") + } + + #[test] + fn activity_to_inbound_file_download_info_maps_to_media_no_auth() { + let atts = serde_json::json!([{ + "contentType": "application/vnd.microsoft.teams.file.download.info", + "name": "report.pdf", + "content": { "downloadUrl": "https://files/anon/report.pdf", "fileType": "pdf" } + }]); + let act = media_activity(atts, "see attached"); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + match msg.content { + crate::MessageContent::Media { text, attachments } => { + assert_eq!(text.as_deref(), Some("see attached")); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].filename, "report.pdf"); + assert_eq!(attachments[0].url, "https://files/anon/report.pdf"); + assert_eq!(attachments[0].mime_type, "pdf"); + assert!(attachments[0].auth_header.is_none()); + } + other => panic!("expected Media, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_inline_image_carries_bearer_auth() { + let atts = serde_json::json!([{ + "contentType": "image/png", + "contentUrl": "https://smba.trafficmanager.net/emea/img/1", + "name": "pasted.png" + }]); + // contentUrl host is *.trafficmanager.net → on the allowlist → bearer attached. + let act = media_activity(atts, ""); + let msg = activity_to_inbound(&act, "teams", Some("TOKEN123")).expect("inbound"); + match msg.content { + crate::MessageContent::Media { text, attachments } => { + assert!(text.is_none(), "empty text -> None"); + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0].url, + "https://smba.trafficmanager.net/emea/img/1" + ); + assert_eq!(attachments[0].mime_type, "image/png"); + assert_eq!( + attachments[0].auth_header.as_deref(), + Some("Bearer TOKEN123") + ); + } + other => panic!("expected Media, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_inline_image_off_allowlist_drops_bearer() { + // I2: a contentUrl pointing at a non-allowlisted host must NOT receive the + // bot's Bot Connector bearer, even when a token is available. + let atts = serde_json::json!([{ + "contentType": "image/png", + "contentUrl": "https://attacker.example/img/1", + "name": "evil.png" + }]); + let act = media_activity(atts, ""); + let msg = activity_to_inbound(&act, "teams", Some("TOKEN123")).expect("inbound"); + match msg.content { + crate::MessageContent::Media { attachments, .. } => { + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].url, "https://attacker.example/img/1"); + assert!( + attachments[0].auth_header.is_none(), + "bearer must not be sent to an off-allowlist host" + ); + } + other => panic!("expected Media, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_inline_image_no_token_drops_bearer() { + // Attachments present but no bot token available (best-effort fetch failed): + // the image is still surfaced, just with no auth_header — file downloads + // that need no auth still work; inline-image fetch will simply 401. + let atts = serde_json::json!([{ + "contentType": "image/png", + "contentUrl": "https://smba.trafficmanager.net/emea/img/1", + "name": "pasted.png" + }]); + let act = media_activity(atts, ""); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + match msg.content { + crate::MessageContent::Media { attachments, .. } => { + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0].url, + "https://smba.trafficmanager.net/emea/img/1" + ); + assert!( + attachments[0].auth_header.is_none(), + "no token -> no bearer even on an allowlisted host" + ); + } + other => panic!("expected Media, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_skips_card_attachments() { + let atts = serde_json::json!([{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { "type": "AdaptiveCard" } + }]); + let act = media_activity(atts, "hello"); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + // No media attachments produced -> stays Text. + assert!(matches!(msg.content, crate::MessageContent::Text(ref t) if t == "hello")); + } + + #[test] + fn activity_to_inbound_no_attachments_stays_text() { + let act = media_activity(serde_json::json!([]), "just text"); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + assert!(matches!(msg.content, crate::MessageContent::Text(ref t) if t == "just text")); + } + + fn submit_activity(value: serde_json::Value) -> Activity { + let raw = serde_json::json!({ + "type": "message", + "id": "click-1", + "from": { "id": "user-1", "name": "Alice" }, + "conversation": { "id": "conv-1", "conversationType": "personal" }, + "serviceUrl": "https://smba.trafficmanager.net/emea/", + "recipient": { "id": "bot-1", "name": "Bot" }, + "replyToId": "card-act-9", + "value": value + }); + serde_json::from_value(raw).expect("activity parses") + } + + #[test] + fn value_to_interaction_extracts_action_id_label_and_values() { + let v = + serde_json::json!({ "action_id": "approve", "label": "Approve", "comment": "lgtm" }); + let c = value_to_interaction(&v, Some("card-act-9".into())); + match c { + crate::MessageContent::Interaction { + action_id, + block_id, + values, + label, + message_ts, + } => { + assert_eq!(action_id, "approve"); + assert_eq!(label.as_deref(), Some("Approve")); + assert!(block_id.is_none()); + assert_eq!(message_ts.as_deref(), Some("card-act-9")); + assert_eq!(values, vec!["lgtm".to_string()]); // extra string field + } + other => panic!("expected Interaction, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_action_submit_becomes_interaction() { + let act = submit_activity(serde_json::json!({ "action_id": "reject", "label": "Reject" })); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + match msg.content { + crate::MessageContent::Interaction { + action_id, + message_ts, + values, + .. + } => { + assert_eq!(action_id, "reject"); + assert_eq!(message_ts.as_deref(), Some("card-act-9")); // replyToId + assert!(values.is_empty()); // plain button, no extra fields + } + other => panic!("expected Interaction, got {other:?}"), + } + } + + #[test] + fn activity_to_inbound_empty_value_is_not_interaction() { + // A normal message with an empty/object-less value must stay Text. + let mut act = submit_activity(serde_json::json!({})); + act.text = Some("hello".into()); + let msg = activity_to_inbound(&act, "teams", None).expect("inbound"); + assert!(matches!(msg.content, crate::MessageContent::Text(ref t) if t == "hello")); + } + + // ----------------------------------------------------------------------- + // TeamsAdapter unit tests + // ----------------------------------------------------------------------- + + use crate::config::TeamsPermissions; + + /// `TeamsAdapter::name()` returns the runtime key supplied at construction. + #[test] + fn adapter_name_returns_runtime_key() { + let perms = Arc::new(arc_swap::ArcSwap::from_pointee(TeamsPermissions::default())); + let adapter = TeamsAdapter::new( + "teams:ops", + "app-id", + "secret", + "common", + 3979, + "0.0.0.0", + perms, + ) + .expect("TeamsAdapter::new"); + assert_eq!(adapter.name(), "teams:ops"); + } + + // ----------------------------------------------------------------------- + // TeamsPermissions::is_allowed tests + // ----------------------------------------------------------------------- + + /// DM sender in dm_allowed_users → allowed. + #[test] + fn permission_dm_allowed_user_is_permitted() { + let perms = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec!["user-aad-123".to_string()], + }; + assert!( + perms.is_allowed(Some("personal"), "user-aad-123", "conv-dm-001"), + "listed DM user should be allowed" + ); + } + + /// DM sender NOT in dm_allowed_users → denied. + #[test] + fn permission_dm_unknown_user_is_denied() { + let perms = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec!["user-aad-123".to_string()], + }; + assert!( + !perms.is_allowed(Some("personal"), "other-user", "conv-dm-001"), + "unlisted DM user should be denied" + ); + } + + /// Empty dm_allowed_users → all DMs blocked. + #[test] + fn permission_empty_dm_list_blocks_all_dms() { + let perms = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec![], + }; + assert!( + !perms.is_allowed(Some("personal"), "any-user", "conv-dm-001"), + "empty dm_allowed_users should block all DMs" + ); + } + + /// No channel_filter → all channels accepted. + #[test] + fn permission_no_channel_filter_allows_any_channel() { + let perms = TeamsPermissions { + channel_filter: None, + dm_allowed_users: vec![], + }; + assert!( + perms.is_allowed(Some("channel"), "user-x", "any-channel-id"), + "None channel_filter should allow all channels" + ); + } + + /// channel_filter present and channel in list → allowed. + #[test] + fn permission_channel_filter_allows_listed_channel() { + let perms = TeamsPermissions { + channel_filter: Some(vec!["ch-allowed".to_string()]), + dm_allowed_users: vec![], + }; + assert!( + perms.is_allowed(Some("channel"), "user-x", "ch-allowed"), + "channel in filter list should be allowed" + ); + } + + /// channel_filter present but channel NOT in list → denied. + #[test] + fn permission_channel_filter_denies_unlisted_channel() { + let perms = TeamsPermissions { + channel_filter: Some(vec!["ch-allowed".to_string()]), + dm_allowed_users: vec![], + }; + assert!( + !perms.is_allowed(Some("channel"), "user-x", "ch-other"), + "channel absent from filter list should be denied" + ); + } + + /// `conversation_type = None` falls through to channel path → filter applies. + #[test] + fn permission_none_conversation_type_treated_as_channel() { + let perms = TeamsPermissions { + channel_filter: Some(vec!["ch-ok".to_string()]), + dm_allowed_users: vec![], + }; + // None type + allowed channel → allowed. + assert!(perms.is_allowed(None, "user-x", "ch-ok")); + // None type + disallowed channel → denied. + assert!(!perms.is_allowed(None, "user-x", "ch-other")); + } + + // ----------------------------------------------------------------------- + // serviceUrl capture helper (inline test of the map logic) + // ----------------------------------------------------------------------- + + /// Inserting a conversation's serviceUrl into the map and reading it back. + #[tokio::test] + async fn service_urls_map_captures_and_retrieves_url() { + let map: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let conv_id = "teams:conv-abc".to_string(); + let url = "https://smba.trafficmanager.net/amer/"; + + map.lock().await.insert(conv_id.clone(), url.to_string()); + + let stored = map.lock().await.get(&conv_id).cloned(); + assert_eq!(stored.as_deref(), Some(url)); + } + + // ----------------------------------------------------------------------- + // SSRF guard tests + // ----------------------------------------------------------------------- + + #[test] + fn ssrf_guard_allows_valid_botframework() { + assert!( + is_allowed_service_url("https://api.botframework.com/"), + "api.botframework.com should be allowed" + ); + } + + #[test] + fn ssrf_guard_allows_trafficmanager() { + assert!( + is_allowed_service_url("https://smba.trafficmanager.net/amer/"), + "smba.trafficmanager.net should be allowed" + ); + } + + #[test] + fn ssrf_guard_rejects_http() { + assert!( + !is_allowed_service_url("http://smba.trafficmanager.net/"), + "http scheme should be rejected" + ); + } + + #[test] + fn ssrf_guard_rejects_evil_host() { + assert!( + !is_allowed_service_url("https://evil.example.com"), + "unrelated host should be rejected" + ); + } + + #[test] + fn ssrf_guard_rejects_evil_suffix() { + assert!( + !is_allowed_service_url("https://botframework.com.evil.com"), + "botframework.com.evil.com should be rejected (evil suffix)" + ); + } + + #[test] + fn ssrf_guard_rejects_invalid_url() { + assert!( + !is_allowed_service_url("not-a-url"), + "invalid URL should be rejected" + ); + } + + // ----------------------------------------------------------------------- + // is_supported broadcast variant tests + // ----------------------------------------------------------------------- + + #[test] + fn is_supported_text_is_true() { + fn is_supported(r: &OutboundResponse) -> bool { + matches!(r, OutboundResponse::Text(_)) + } + assert!( + is_supported(&OutboundResponse::Text("hello".to_string())), + "Text should be supported for broadcast" + ); + } + + #[test] + fn is_supported_other_is_false() { + fn is_supported(r: &OutboundResponse) -> bool { + matches!(r, OutboundResponse::Text(_)) + } + assert!( + !is_supported(&OutboundResponse::Reaction("👍".to_string())), + "Reaction should not be supported for broadcast" + ); + } + + // ----------------------------------------------------------------------- + // Conversation ID strip tests + // ----------------------------------------------------------------------- + + #[test] + fn conv_id_strip_default() { + let conv_id = "teams:conv-abc"; + let runtime_key = "teams"; + let bare = conv_id + .strip_prefix(&format!("{runtime_key}:")) + .unwrap_or(conv_id); + assert_eq!(bare, "conv-abc"); + } + + #[test] + fn conv_id_strip_named() { + let conv_id = "teams:prod:conv-abc"; + let runtime_key = "teams:prod"; + let bare = conv_id + .strip_prefix(&format!("{runtime_key}:")) + .unwrap_or(conv_id); + assert_eq!(bare, "conv-abc"); + } + + #[test] + fn conv_id_strip_colon_in_id() { + // Inner colons in the MS id must be preserved. + let conv_id = "teams:conv:abc:def"; + let runtime_key = "teams"; + let bare = conv_id + .strip_prefix(&format!("{runtime_key}:")) + .unwrap_or(conv_id); + assert_eq!(bare, "conv:abc:def"); + } + + // ----------------------------------------------------------------------- + // strip_at_mentions — attributed variant (FIX 4) + // ----------------------------------------------------------------------- + + /// Teams sends `BotName` when the mention has an id + /// attribute. strip_at_mentions must strip those spans too. + #[test] + fn strip_at_mentions_attributed_tag() { + let raw = r#"Bot hello"#; + let stripped = strip_at_mentions(raw); + assert_eq!( + stripped, "hello", + "attributed should be stripped" + ); + } + + /// bot_was_mentioned text-fallback must fire for `` when + /// entities are absent (exercises the `contains("Bot hello", + "from": { "id": "user-x", "name": "X" }, + "conversation": { "id": "conv-attr", "conversationType": "channel" }, + "serviceUrl": "https://smba.trafficmanager.net/amer/", + "recipient": { "id": "bot-attr", "name": "Bot" }, + "channelData": {} + }"#; + let activity: Activity = serde_json::from_str(raw).expect("parse activity"); + assert!( + bot_was_mentioned(&activity), + "attributed tag should trigger mentioned=true via text fallback" + ); + // Also verify the text is stripped correctly end-to-end. + let msg = + activity_to_inbound(&activity, "teams", None).expect("should produce InboundMessage"); + if let crate::MessageContent::Text(text) = &msg.content { + assert_eq!(text, "hello"); + } else { + panic!("expected Text content"); + } + assert_eq!( + msg.metadata.get("teams_mentioned").and_then(|v| v.as_str()), + Some("true") + ); + } + + // ----------------------------------------------------------------------- + // SSRF guard — additional regression cases (FIX extra) + // ----------------------------------------------------------------------- + + #[test] + fn ssrf_guard_rejects_userinfo() { + // Userinfo in URL can be used to bypass naive host checks. + assert!( + !is_allowed_service_url("https://botframework.com@evil.com/foo"), + "userinfo-based bypass must be rejected" + ); + } + + #[test] + fn ssrf_guard_rejects_metadata_ip() { + assert!( + !is_allowed_service_url("https://169.254.169.254/latest/meta-data/"), + "link-local metadata IP must be rejected" + ); + } + + #[test] + fn ssrf_guard_rejects_localhost() { + assert!( + !is_allowed_service_url("https://localhost/"), + "localhost must be rejected" + ); + } + + #[test] + fn ssrf_guard_accepts_real_trafficmanager() { + assert!( + is_allowed_service_url("https://smba.trafficmanager.net/amer/"), + "real trafficmanager.net URL must be accepted" + ); + } + + // ----------------------------------------------------------------------- + // Map-key fix test + // ----------------------------------------------------------------------- + + #[test] + fn map_key_named_instance() { + use crate::messaging::apply_runtime_adapter_to_conversation_id; + + let runtime_key = "teams:prod"; + let ms_id = "conv-abc"; + let base = format!("teams:{ms_id}"); + let rewritten = apply_runtime_adapter_to_conversation_id(runtime_key, base); + + assert_eq!( + rewritten, "teams:prod:conv-abc", + "named instance should produce teams:prod:conv-abc, not teams:conv-abc" + ); + } + + #[test] + fn activities_url_builds_expected_path() { + assert_eq!( + activities_url("https://smba.trafficmanager.net/emea/", "conv:abc"), + "https://smba.trafficmanager.net/emea/v3/conversations/conv:abc/activities" + ); + // Any trailing slash(es) are trimmed. + assert_eq!( + activities_url("https://x.botframework.com", "c1"), + "https://x.botframework.com/v3/conversations/c1/activities" + ); + } + + #[test] + fn strip_runtime_prefix_default_named_and_inner_colons() { + assert_eq!(strip_runtime_prefix("teams:conv-abc", "teams"), "conv-abc"); + assert_eq!( + strip_runtime_prefix("teams:prod:conv-abc", "teams:prod"), + "conv-abc" + ); + assert_eq!( + strip_runtime_prefix("teams:conv:abc:def", "teams"), + "conv:abc:def" + ); + // No prefix match → returned unchanged. + assert_eq!(strip_runtime_prefix("conv-abc", "teams"), "conv-abc"); + } + + // ----------------------------------------------------------------------- + // Adaptive Card rendering tests (Task 2) + // ----------------------------------------------------------------------- + + #[test] + fn card_to_adaptive_maps_core_fields() { + let card = crate::Card { + title: Some("Deploy ready".into()), + description: Some("Click to approve".into()), + color: Some(0x00ff00), + url: None, + fields: vec![ + crate::CardField { + name: "Env".into(), + value: "prod".into(), + inline: true, + }, + crate::CardField { + name: "Build".into(), + value: "#42".into(), + inline: false, + }, + ], + footer: Some(crate::CardFooter { + text: "by ci-bot".into(), + icon_url: None, + }), + thumbnail: None, + image: Some(crate::CardImage { + url: "https://img/x.png".into(), + }), + author: None, + timestamp: Some("2026-06-26T10:00:00Z".into()), + }; + let v = card_to_adaptive(&card); + assert_eq!(v["type"], "AdaptiveCard"); + assert_eq!(v["version"], "1.5"); + let body = v["body"].as_array().expect("body array"); + // Title TextBlock present, Bolder/Large. + let title = body + .iter() + .find(|e| e["text"] == "Deploy ready") + .expect("title block"); + assert_eq!(title["type"], "TextBlock"); + assert_eq!(title["weight"], "Bolder"); + assert_eq!(title["size"], "Large"); + // Description present. + assert!(body.iter().any(|e| e["text"] == "Click to approve")); + // Image element present. + assert!( + body.iter() + .any(|e| e["type"] == "Image" && e["url"] == "https://img/x.png") + ); + // FactSet with both fields. + let facts = body + .iter() + .find(|e| e["type"] == "FactSet") + .expect("factset"); + let facts = facts["facts"].as_array().expect("facts array"); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0]["title"], "Env"); + assert_eq!(facts[0]["value"], "prod"); + // Footer carries the timestamp. + assert!(body.iter().any(|e| { + e["isSubtle"] == true + && e["text"].as_str().unwrap_or("").contains("by ci-bot") + && e["text"] + .as_str() + .unwrap_or("") + .contains("2026-06-26T10:00:00Z") + })); + // color is NOT emitted anywhere. + let s = v.to_string(); + assert!( + s.find("65280").is_none(), + "raw color value must not leak into the card" + ); + assert!( + s.find("\"color\"").is_none(), + "no color key in the Adaptive Card" + ); + } + + #[test] + fn card_to_adaptive_empty_card_has_empty_body() { + let card = crate::Card { + title: None, + description: None, + color: None, + url: None, + fields: vec![], + footer: None, + thumbnail: None, + image: None, + author: None, + timestamp: None, + }; + let v = card_to_adaptive(&card); + assert_eq!(v["type"], "AdaptiveCard"); + assert_eq!(v["body"].as_array().expect("body array").len(), 0); + } + + #[test] + fn card_to_adaptive_title_with_url_is_markdown_link() { + let card = crate::Card { + title: Some("Open PR".into()), + description: None, + color: None, + url: Some("https://github.com/x/y/pull/1".into()), + fields: vec![], + footer: None, + thumbnail: None, + image: None, + author: None, + timestamp: None, + }; + let v = card_to_adaptive(&card); + let body = v["body"].as_array().unwrap(); + assert!( + body.iter().any(|e| e["type"] == "TextBlock" + && e["text"] == "[Open PR](https://github.com/x/y/pull/1)") + ); + } + + #[test] + fn cards_to_attachments_wraps_each_card() { + let cards = vec![ + crate::Card { + title: Some("A".into()), + description: None, + color: None, + url: None, + fields: vec![], + footer: None, + thumbnail: None, + image: None, + author: None, + timestamp: None, + }, + crate::Card { + title: Some("B".into()), + description: None, + color: None, + url: None, + fields: vec![], + footer: None, + thumbnail: None, + image: None, + author: None, + timestamp: None, + }, + ]; + let atts = cards_to_attachments(&cards); + assert_eq!(atts.len(), 2); + assert_eq!( + atts[0]["contentType"], + "application/vnd.microsoft.card.adaptive" + ); + assert_eq!(atts[0]["content"]["type"], "AdaptiveCard"); + assert_eq!(atts[1]["content"]["body"][0]["text"], "B"); + assert_eq!( + atts[0]["content"]["$schema"], + "http://adaptivecards.io/schemas/adaptive-card.json" + ); + } + + #[test] + fn typing_activity_body_is_typing_type() { + let b = typing_activity_body(); + assert_eq!(b["type"], "typing"); + // No text field — a bare typing activity. + assert!(b.get("text").is_none()); + } + + #[tokio::test] + async fn stop_typing_removes_and_aborts_the_task() { + let perms = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + crate::config::TeamsPermissions::default(), + )); + let adapter = TeamsAdapter::new("teams", "app", "secret", "tenant", 0, "127.0.0.1", perms) + .expect("adapter"); + // Insert a long-lived dummy task under a conversation key. + let handle = tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }); + adapter + .typing_tasks + .write() + .await + .insert("teams:conv-1".to_string(), handle); + assert_eq!(adapter.typing_tasks.read().await.len(), 1); + adapter.stop_typing("teams:conv-1").await; + assert!(adapter.typing_tasks.read().await.is_empty()); + // Stopping an unknown conversation is a no-op. + adapter.stop_typing("teams:unknown").await; + assert!(adapter.typing_tasks.read().await.is_empty()); + } + + #[test] + fn build_message_body_omits_optional_keys_when_empty() { + let body = build_message_body("hello", &[], None); + assert_eq!(body["type"], "message"); + assert_eq!(body["text"], "hello"); + assert!( + body.get("attachments").is_none(), + "no attachments key when empty" + ); + assert!( + body.get("replyToId").is_none(), + "no replyToId key when None" + ); + } + + #[test] + fn build_message_body_includes_cards_and_reply_to() { + let cards = vec![crate::Card { + title: Some("Hi".into()), + description: None, + color: None, + url: None, + fields: vec![], + footer: None, + thumbnail: None, + image: None, + author: None, + timestamp: None, + }]; + let attachments = cards_to_attachments(&cards); + let body = build_message_body("Hi", &attachments, Some("act-99")); + assert_eq!(body["text"], "Hi"); + assert_eq!(body["replyToId"], "act-99"); + assert_eq!( + body["attachments"][0]["contentType"], + "application/vnd.microsoft.card.adaptive" + ); + assert_eq!(body["attachments"][0]["content"]["body"][0]["text"], "Hi"); + } + + // ----------------------------------------------------------------------- + // v2b: button_to_action / interactive_elements_to_actions / actions_card_attachment + // ----------------------------------------------------------------------- + + #[test] + fn button_to_action_submit_embeds_action_id_and_label() { + let btn = crate::Button { + label: "Approve".into(), + custom_id: Some("approve_42".into()), + style: crate::ButtonStyle::Primary, + url: None, + }; + let a = button_to_action(&btn); + assert_eq!(a["type"], "Action.Submit"); + assert_eq!(a["title"], "Approve"); + assert_eq!(a["data"]["action_id"], "approve_42"); + assert_eq!(a["data"]["label"], "Approve"); + } + + #[test] + fn button_to_action_url_is_openurl() { + let btn = crate::Button { + label: "Docs".into(), + custom_id: None, + style: crate::ButtonStyle::Link, + url: Some("https://example.com/docs".into()), + }; + let a = button_to_action(&btn); + assert_eq!(a["type"], "Action.OpenUrl"); + assert_eq!(a["title"], "Docs"); + assert_eq!(a["url"], "https://example.com/docs"); + } + + #[test] + fn button_to_action_submit_falls_back_to_label_id() { + let btn = crate::Button { + label: "Yes".into(), + custom_id: None, + style: crate::ButtonStyle::Secondary, + url: None, + }; + let a = button_to_action(&btn); + assert_eq!(a["type"], "Action.Submit"); + assert_eq!(a["data"]["action_id"], "Yes"); + } + + #[test] + fn interactive_elements_to_actions_flattens_buttons_and_skips_select() { + let elems = vec![ + crate::InteractiveElements::Buttons { + buttons: vec![ + crate::Button { + label: "A".into(), + custom_id: Some("a".into()), + style: crate::ButtonStyle::Primary, + url: None, + }, + crate::Button { + label: "B".into(), + custom_id: Some("b".into()), + style: crate::ButtonStyle::Danger, + url: None, + }, + ], + }, + crate::InteractiveElements::Select { + select: crate::SelectMenu { + custom_id: "s".into(), + options: vec![], + placeholder: None, + }, + }, + ]; + let actions = interactive_elements_to_actions(&elems); + assert_eq!(actions.len(), 2, "2 buttons, select skipped in v2b"); + assert_eq!(actions[0]["data"]["action_id"], "a"); + assert_eq!(actions[1]["data"]["action_id"], "b"); + } + + #[test] + fn actions_card_attachment_carries_text_and_actions() { + let actions = + vec![serde_json::json!({"type":"Action.Submit","title":"X","data":{"action_id":"x"}})]; + let att = actions_card_attachment("Pick one:", actions); + assert_eq!( + att["contentType"], + "application/vnd.microsoft.card.adaptive" + ); + assert_eq!(att["content"]["type"], "AdaptiveCard"); + assert_eq!(att["content"]["body"][0]["text"], "Pick one:"); + assert_eq!(att["content"]["actions"][0]["data"]["action_id"], "x"); + } + + #[test] + fn rich_message_with_buttons_appends_action_card() { + let elems = vec![crate::InteractiveElements::Buttons { + buttons: vec![crate::Button { + label: "Approve".into(), + custom_id: Some("ok".into()), + style: crate::ButtonStyle::Primary, + url: None, + }], + }]; + let actions = interactive_elements_to_actions(&elems); + let mut atts: Vec = Vec::new(); + if !actions.is_empty() { + atts.push(actions_card_attachment("Approve?", actions)); + } + let body = build_message_body("Approve?", &atts, None); + assert_eq!(body["text"], "Approve?"); + assert_eq!( + body["attachments"][0]["content"]["actions"][0]["data"]["action_id"], + "ok" + ); + } +} diff --git a/src/secrets/store.rs b/src/secrets/store.rs index 2c70b48ef..f0fb9b645 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -1413,7 +1413,7 @@ pub trait SystemSecrets { pub fn system_secret_registry() -> Vec<&'static SecretField> { use crate::config::{ DefaultsConfig, DiscordConfig, EmailConfig, LlmConfig, MattermostConfig, SignalConfig, - SlackConfig, TelegramConfig, TwitchConfig, + SlackConfig, TeamsConfig, TelegramConfig, TwitchConfig, }; let mut fields = Vec::new(); @@ -1429,6 +1429,7 @@ pub fn system_secret_registry() -> Vec<&'static SecretField> { fields.extend(EmailConfig::secret_fields()); fields.extend(SignalConfig::secret_fields()); fields.extend(MattermostConfig::secret_fields()); + fields.extend(TeamsConfig::secret_fields()); fields } @@ -2097,6 +2098,31 @@ mod tests { "missing EMAIL_IMAP_PASSWORD" ); + // Teams adapter secrets must be present and auto-categorised as System + // (absence would allow TEAMS_CLIENT_SECRET to fall through to SecretCategory::Tool + // and be injected into worker prompts). + assert!( + has_secret("TEAMS_CLIENT_SECRET"), + "missing TEAMS_CLIENT_SECRET" + ); + assert!(has_secret("TEAMS_APP_ID"), "missing TEAMS_APP_ID"); + assert!(has_secret("TEAMS_TENANT_ID"), "missing TEAMS_TENANT_ID"); + assert_eq!( + auto_categorize("TEAMS_CLIENT_SECRET"), + SecretCategory::System, + "TEAMS_CLIENT_SECRET must be System, not Tool" + ); + assert_eq!( + auto_categorize("TEAMS_APP_ID"), + SecretCategory::System, + "TEAMS_APP_ID must be System, not Tool" + ); + assert_eq!( + auto_categorize("TEAMS_TENANT_ID"), + SecretCategory::System, + "TEAMS_TENANT_ID must be System, not Tool" + ); + // Adapter fields have instance patterns, LLM fields don't. let discord_field = fields .iter() From 9a94d9576a3164a6e3a6d4403647f080fd9fe648 Mon Sep 17 00:00:00 2001 From: "S.L" <101876007+slvnlrt@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:04:42 +0200 Subject: [PATCH 2/8] =?UTF-8?q?fix(teams):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20single-instance=20routing,=20hot-reload=20warning,=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - target.rs: Teams runs a single listener in v1, so broadcast/delivery target resolution always uses the adapter "teams" and keeps the full conversation id (colons preserved). Drop the instance-name heuristic that misread opaque ids like a DM "a:1-..." as a named instance "teams:a" and mis-routed proactive sends to a non-existent adapter. Update tests; add DM-id regressions. - watcher.rs: warn on named [[messaging.teams.instances]] during hot reload, matching cold start (named instances are not started in v1). - permissions.rs: correct the dm_allowed_users doc to match the actual compare target (the inbound activity.from.id MRI), not AAD object IDs. Note: the hot-reload "fail-open on disable" raised in review is the shared behavior of every adapter (removing config never stops a running adapter), not Teams-specific; tightening only Teams would break cross-adapter parity, so it is intentionally out of scope here. --- src/config/permissions.rs | 5 +- src/config/watcher.rs | 13 ++++- src/messaging/target.rs | 113 +++++++++++++++++++------------------- 3 files changed, 70 insertions(+), 61 deletions(-) diff --git a/src/config/permissions.rs b/src/config/permissions.rs index c80a0f59f..24db4290e 100644 --- a/src/config/permissions.rs +++ b/src/config/permissions.rs @@ -545,8 +545,9 @@ impl MattermostPermissions { pub struct TeamsPermissions { /// Allowed Teams channel IDs (None = all channels accepted). pub channel_filter: Option>, - /// AAD object IDs (or UPN strings) allowed to DM the bot. - /// Empty = DMs blocked entirely. + /// Teams user IDs allowed to DM the bot, matched against the inbound + /// `activity.from.id` (the user's MRI, e.g. `29:...`). A `"*"` entry + /// allows any DM sender. Empty = DMs blocked entirely. pub dm_allowed_users: Vec, } diff --git a/src/config/watcher.rs b/src/config/watcher.rs index 8f23d1e23..33bc754a4 100644 --- a/src/config/watcher.rs +++ b/src/config/watcher.rs @@ -699,8 +699,7 @@ pub fn spawn_file_watcher( // Teams: start default instance only (single-instance in v1). if let Some(teams_config) = &config.messaging.teams && teams_config.enabled - { - if !teams_config.app_id.is_empty() + && !teams_config.app_id.is_empty() && !teams_config.client_secret.is_empty() && !teams_config.tenant_id.is_empty() && !manager.has_adapter("teams").await @@ -732,6 +731,16 @@ pub fn spawn_file_watcher( } } } + + // Named Teams instances cannot bind their own listener in v1; + // warn on live config edits too (mirrors cold start in main.rs). + if let Some(teams_config) = &config.messaging.teams + && teams_config.instances.iter().any(|i| i.enabled) + { + tracing::warn!( + "Teams v1 supports a single listener per port; named \ + [[messaging.teams.instances]] are NOT started on config reload" + ); } }); } diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 1227af058..e6744f95a 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -164,16 +164,14 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option { - // Teams conversation IDs can contain colons (e.g. "19:meeting_abc==@thread.tacv2"). - // We must NOT split greedily — instead strip only the known platform (and optional - // instance) prefix verbatim and keep the remainder as the bare conversation id. - // - // Channel id formats: - // "teams:{conversation_id}" → adapter "teams" - // "teams:{instance}:{conversation_id}" → adapter "teams:{instance}" - // - // The `teams_conversation_id` metadata key is preferred when present so we can - // round-trip correctly even if the channel id is malformed. + // v1 runs a single Teams listener, so the adapter is always "teams". + // Teams conversation IDs are opaque and contain colons (e.g. + // "19:meeting_abc==@thread.tacv2", or a DM id "a:1-..."). We must NOT + // try to infer a named instance from the first segment — segments like + // "a" or "prod" are part of the conversation id, not an instance name. + // Everything after the "teams:" prefix is the bare conversation id, + // verbatim. The `teams_conversation_id` metadata key is preferred when + // present so we round-trip correctly even if the channel id is malformed. if let Some(conv_id) = channel .platform_meta .as_ref() @@ -186,31 +184,18 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option Option { } match parts { + // Teams: single-instance in v1. The adapter is always "teams" and everything + // after the prefix is the opaque (colon-containing) conversation id. Never infer + // a named instance — Teams ids can start with short segments ("a", "prod") that + // would otherwise be misread as instance names and routed to a missing adapter. + ["teams", ..] => { + let conv_id = parts[1..].join(":"); + let normalized = normalize_target("teams", &conv_id)?; + Some(BroadcastTarget { + adapter: "teams".to_string(), + target: normalized, + }) + } // "dm" reserved for Slack/Discord (case-insensitive) [platform @ ("discord" | "slack"), instance, user_id] if instance.eq_ignore_ascii_case("dm") && !user_id.is_empty() => @@ -721,21 +718,6 @@ fn parse_named_instance_target(parts: &[&str]) -> Option { target: normalized, }) } - // Teams multi-part conversation IDs: teams:{rest} where rest contains colons. - // The second segment was NOT a valid instance name (handled by the named-adapter arm - // above), so the entire `parts[1..]` sequence is the bare conversation id. - // We reconstruct it verbatim via join to preserve the colons. - ["teams", ..] if parts.len() > 2 => { - let conv_id = parts[1..].join(":"); - if conv_id.is_empty() { - return None; - } - let normalized = normalize_teams_target(&conv_id)?; - Some(BroadcastTarget { - adapter: "teams".to_string(), - target: normalized, - }) - } // Default adapter: platform:target [platform, target] if !target.is_empty() => { let normalized = normalize_target(platform, target)?; @@ -1205,15 +1187,30 @@ mod tests { ); } - /// `teams:{instance}:{id}` named round-trips with correct adapter and target. + /// v1 is single-instance: a leading segment that looks like an instance name + /// ("prod") is kept as part of the conversation id; the adapter stays "teams". #[test] - fn parse_teams_named_instance_target() { + fn parse_teams_instance_segment_kept_in_conversation_id() { let parsed = parse_delivery_target("teams:prod:conv-ch-999"); assert_eq!( parsed, Some(super::BroadcastTarget { - adapter: "teams:prod".to_string(), - target: "conv-ch-999".to_string(), + adapter: "teams".to_string(), + target: "prod:conv-ch-999".to_string(), + }) + ); + } + + /// Regression: a Teams DM conversation id ("a:1-...") must not be misread as a + /// named instance "teams:a". The whole id is preserved; adapter stays "teams". + #[test] + fn parse_teams_dm_id_not_misread_as_instance() { + let parsed = parse_delivery_target("teams:a:1-conv_abc_def"); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "teams".to_string(), + target: "a:1-conv_abc_def".to_string(), }) ); } @@ -1231,15 +1228,15 @@ mod tests { ); } - /// Named instance + conversation ID containing a colon. + /// Multi-colon ids are preserved verbatim; no instance is inferred. #[test] - fn parse_teams_named_instance_with_colon_in_id() { + fn parse_teams_multipart_id_kept_verbatim() { let parsed = parse_delivery_target("teams:prod:19:meeting_abc==@thread.tacv2"); assert_eq!( parsed, Some(super::BroadcastTarget { - adapter: "teams:prod".to_string(), - target: "19:meeting_abc==@thread.tacv2".to_string(), + adapter: "teams".to_string(), + target: "prod:19:meeting_abc==@thread.tacv2".to_string(), }) ); } @@ -1264,16 +1261,18 @@ mod tests { ); } - /// `resolve_broadcast_target` for a named-instance teams channel returns the correct adapter. + /// Regression: a Teams DM channel id ("teams:a:1-...") must not be misread as a + /// named-instance adapter ("teams:a"). v1 is single-instance; adapter stays "teams" + /// and the whole conversation id is preserved. #[test] - fn resolve_teams_named_instance_broadcast_target() { - let channel = test_channel_info("teams:prod:19:meeting_abc==@thread.tacv2", "teams"); + fn resolve_teams_dm_id_not_misread_as_instance() { + let channel = test_channel_info("teams:a:1-conv_abc_def", "teams"); let resolved = resolve_broadcast_target(&channel); assert_eq!( resolved, Some(super::BroadcastTarget { - adapter: "teams:prod".to_string(), - target: "19:meeting_abc==@thread.tacv2".to_string(), + adapter: "teams".to_string(), + target: "a:1-conv_abc_def".to_string(), }) ); } From 104df402c76f2617c23da3a5a262973a254a6b53 Mon Sep 17 00:00:00 2001 From: "S.L" <101876007+slvnlrt@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:05:47 +0200 Subject: [PATCH 3/8] style(teams): satisfy clippy (collapsible-if, too_many_arguments) Collapse a nested if in card_to_adaptive and annotate build_teams_adapter with #[allow(clippy::too_many_arguments)] (discrete args mirror TeamsAdapter::new). Keeps the clippy-all gate clean. --- src/messaging/teams.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/messaging/teams.rs b/src/messaging/teams.rs index be84b980f..97c0e34a6 100644 --- a/src/messaging/teams.rs +++ b/src/messaging/teams.rs @@ -909,13 +909,13 @@ fn activities_url(service_url: &str, bare_conv_id: &str) -> String { fn card_to_adaptive(card: &crate::Card) -> serde_json::Value { let mut body: Vec = Vec::new(); - if let Some(author) = &card.author { - if !author.name.trim().is_empty() { - body.push(serde_json::json!({ - "type": "TextBlock", "text": author.name, "weight": "Bolder", - "isSubtle": true, "wrap": true, "spacing": "None" - })); - } + if let Some(author) = &card.author + && !author.name.trim().is_empty() + { + body.push(serde_json::json!({ + "type": "TextBlock", "text": author.name, "weight": "Bolder", + "isSubtle": true, "wrap": true, "spacing": "None" + })); } // Title (linked if a url is present); a bare url with no title still links. @@ -1318,6 +1318,9 @@ impl TeamsAdapter { /// /// This is the canonical constructor used by both the daemon startup path and /// the config-watcher reload path so the two never drift apart. +// Discrete credential/listener args mirror `TeamsAdapter::new`; bundling them +// into a struct would only move the argument list one call deeper. +#[allow(clippy::too_many_arguments)] pub fn build_teams_adapter( runtime_key: impl Into, app_id: impl Into, From 3ed93347a1d436f9053242099dabb049a4a14f04 Mon Sep 17 00:00:00 2001 From: "S.L" <101876007+slvnlrt@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:38:27 +0200 Subject: [PATCH 4/8] feat(interface): add Microsoft Teams to the Channels settings UI Connect, update, and remove a Teams bot from Settings -> Channels, matching the other single-instance adapters. - Frontend: a Teams credential form (App ID, Client Secret, Tenant ID), the platform catalog entry and icon, and single-instance gating. - Messaging API: create / delete / status handlers for Teams. The create handler writes the [messaging.teams] config; the config watcher (added with the adapter) starts it. Regenerates the OpenAPI TypeScript bindings. - Docs: adds the Spacebot UI tab to the Teams setup guide. --- docs/content/docs/(messaging)/teams-setup.mdx | 17 +++ interface/src/api/schema.d.ts | 95 +++++++++++- .../src/components/ChannelSettingCard.tsx | 81 ++++++++++- .../components/settings/ChannelsSection.tsx | 2 + interface/src/components/settings/types.ts | 3 +- interface/src/lib/platformIcons.tsx | 3 +- src/api/messaging.rs | 136 +++++++++++++++++- 7 files changed, 330 insertions(+), 7 deletions(-) diff --git a/docs/content/docs/(messaging)/teams-setup.mdx b/docs/content/docs/(messaging)/teams-setup.mdx index 2c891a5ba..927527fca 100644 --- a/docs/content/docs/(messaging)/teams-setup.mdx +++ b/docs/content/docs/(messaging)/teams-setup.mdx @@ -47,6 +47,20 @@ Upload the package in Teams (**Apps** → **Manage your apps** → **Upload an a ## Step 4: Configure Spacebot + + + +1. Open your Spacebot dashboard +2. Go to **Settings** → **Channels** +3. Click **Setup** on the Microsoft Teams card +4. Paste your **App ID**, **Client Secret**, and **Tenant ID** from the steps above +5. Click **Save** + +Spacebot writes the config and starts the adapter. Make sure the bot's port is reachable through your public HTTPS endpoint. + + + + Add a `[messaging.teams]` block to your config. Keep the client secret out of the file by referencing an environment variable. ```toml @@ -78,6 +92,9 @@ Start Spacebot with the secret in the environment: TEAMS_CLIENT_SECRET="your-secret-value" spacebot start ``` + + + ## DM permissions Channel and group messages are open: the bot replies wherever it is @mentioned. Direct messages are **fail-closed** — an empty `dm_allowed_users` blocks every DM. diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index 01ec025cd..9dbd9cbbc 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -685,7 +685,7 @@ export interface paths { }; /** * Serve a saved attachment file. - * @description Streams the file from disk with the correct Content-Type. + * @description Reads the file from disk with the correct Content-Type. * Use `?download=true` to force a download prompt. * Use `?thumbnail=true` to request a thumbnail (currently serves full file). */ @@ -736,6 +736,28 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/{agent_id}/wake": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Manually wake a (typically dormant) agent. + * @description Fires the same wake path that `send_agent_message`, cron, and other + * trigger sources use. Useful for debugging dormant deployments and + * recovering an agent stuck on a missed trigger. + */ + post: operations["wake_agent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bindings": { parameters: { query?: never; @@ -3160,6 +3182,11 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Scope this secret belongs to. Older exports without a `scope` field + * import as `InstanceShared` so existing backups continue to load. + */ + scope?: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; value: string; @@ -3272,6 +3299,9 @@ export interface components { signal_http_url?: string | null; slack_app_token?: string | null; slack_bot_token?: string | null; + teams_app_id?: string | null; + teams_client_secret?: string | null; + teams_tenant_id?: string | null; telegram_token?: string | null; twitch_client_id?: string | null; twitch_client_secret?: string | null; @@ -3923,12 +3953,36 @@ export interface components { /** Format: date-time */ created_at: string; name: string; + /** + * @description Visibility scope. `InstanceShared` is the default for system / + * admin-managed secrets; `Agent` rows are per-agent tool credentials. + */ + scope: components["schemas"]["SecretScope"]; /** Format: date-time */ updated_at: string; }; SecretListResponse: { secrets: components["schemas"]["SecretListItem"][]; }; + /** + * @description Secret scope determines visibility across agents on a shared instance. + * + * Orthogonal to `SecretCategory` — `System` secrets are always + * `InstanceShared` (singleton consumers like `LlmManager` / + * `MessagingManager`); `Tool` secrets default to `Agent(...)` for + * agentic-backend deployments where each tenant's worker subprocess must + * not see another tenant's credentials, but can also be `InstanceShared` + * when a single-tenant deployment legitimately wants every agent to share + * the same `Tool` secret (e.g. one repo-wide `GH_TOKEN`). + */ + SecretScope: { + /** @enum {string} */ + kind: "instance_shared"; + } | { + agent_id: string; + /** @enum {string} */ + kind: "agent"; + }; SetChannelArchiveRequest: { agent_id: string; archived: boolean; @@ -4079,6 +4133,8 @@ export interface components { /** Format: int64 */ reasoning: number; }; + /** @enum {string} */ + ToolResultStatus: "pending" | "final" | "waiting_for_input"; ToolsResponse: { binaries: components["schemas"]["BinaryEntry"][]; tools_bin: string; @@ -4133,7 +4189,10 @@ export interface components { type: "system_text"; } | { call_id: string; + /** @description Accumulated streaming output for live display. Cleared when tool completes. */ + live_output?: string | null; name: string; + status?: components["schemas"]["ToolResultStatus"]; text: string; /** @enum {string} */ type: "tool_result"; @@ -4340,6 +4399,11 @@ export interface components { /** Format: int64 */ request_count: number; }; + WakeAgentResponse: { + agent_id: string; + fired: boolean; + message: string; + }; WarmupSection: { eager_embedding_load: boolean; enabled: boolean; @@ -6483,6 +6547,35 @@ export interface operations { }; }; }; + wake_agent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Agent ID */ + agent_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WakeAgentResponse"]; + }; + }; + /** @description Wake manager not running */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_bindings: { parameters: { query?: { diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index 3f45baacd..56354084d 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -40,7 +40,8 @@ type Platform = | "email" | "webhook" | "mattermost" - | "signal"; + | "signal" + | "teams"; const PLATFORM_LABELS: Record = { discord: "Discord", @@ -51,6 +52,7 @@ const PLATFORM_LABELS: Record = { webhook: "Webhook", mattermost: "Mattermost", signal: "Signal", + teams: "Microsoft Teams", }; const DOC_LINKS: Partial> = { @@ -60,6 +62,7 @@ const DOC_LINKS: Partial> = { twitch: "https://docs.spacebot.sh/twitch-setup", mattermost: "https://docs.spacebot.sh/mattermost-setup", signal: "https://docs.spacebot.sh/signal-setup", + teams: "https://docs.spacebot.sh/teams-setup", }; // --- Platform Catalog (Left Column) --- @@ -78,6 +81,7 @@ export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) { "webhook", "mattermost", "signal", + "teams", ]; const COMING_SOON = [ @@ -811,6 +815,22 @@ export function AddInstanceCard({ credentials.signal_dm_allowed_users = result.entries.join(","); } } + } else if (platform === "teams") { + if ( + !credentialInputs.teams_app_id?.trim() || + !credentialInputs.teams_client_secret?.trim() || + !credentialInputs.teams_tenant_id?.trim() + ) { + setMessage({ + text: "App ID, client secret, and tenant ID are required", + type: "error", + }); + return; + } + credentials.teams_app_id = credentialInputs.teams_app_id.trim(); + credentials.teams_client_secret = + credentialInputs.teams_client_secret.trim(); + credentials.teams_tenant_id = credentialInputs.teams_tenant_id.trim(); } if (!isDefault && !instanceName.trim()) { @@ -1304,6 +1324,63 @@ export function AddInstanceCard({ )} + {platform === "teams" && ( + <> +
+ + + setCredentialInputs({ + ...credentialInputs, + teams_app_id: e.target.value, + }) + } + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + teams_client_secret: e.target.value, + }) + } + placeholder="Your Azure app client secret" + /> +
+
+ + + setCredentialInputs({ + ...credentialInputs, + teams_tenant_id: e.target.value, + }) + } + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + }} + /> +
+ + )} + {platform === "signal" && ( <>
@@ -1521,7 +1598,7 @@ function BindingForm({
)} - {(platform === "discord" || platform === "slack") && ( + {(platform === "discord" || platform === "slack" || platform === "teams") && (
+ {(credentialInputs.teams_app_id ?? "").trim() !== "" && ( + <> + +

+ The package uses the default Spacebot icon. To use your own, unzip it, replace + icon-color.png (192×192) and icon-outline.png (32×32), and re-zip. +

+ + )} )} From 44f913d122b46321c32a1b0d35d5d809e106a136 Mon Sep 17 00:00:00 2001 From: "S.L" <101876007+slvnlrt@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:07:09 +0200 Subject: [PATCH 8/8] docs(teams): document the Download-app-package button + manual/icon-swap path --- docs/content/docs/(messaging)/teams-setup.mdx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/(messaging)/teams-setup.mdx b/docs/content/docs/(messaging)/teams-setup.mdx index 927527fca..a590512a5 100644 --- a/docs/content/docs/(messaging)/teams-setup.mdx +++ b/docs/content/docs/(messaging)/teams-setup.mdx @@ -37,13 +37,48 @@ Then open **Channels** and add the **Microsoft Teams** channel. ## Step 3: Build and install the Teams app -Teams needs an app package (a zip with a `manifest.json` and two icons) to surface the bot to users. +Teams needs an app package (a zip with a `manifest.json` and two icons). + + + + +1. In **Settings → Channels**, open the Microsoft Teams card and enter your **App ID** (Step 1). +2. Click **Download Teams app package**. Spacebot builds a correct `manifest.json` (with the right schema, a distinct app id, and the Spacebot icon) and downloads `spacebot-teams-app.zip`. +3. Upload it in Teams (**Apps → Manage your apps → Upload an app**). + +The package uses the default Spacebot icon. To use your own, unzip it, replace `icon-color.png` (192×192) and `icon-outline.png` (32×32, transparent), and re-zip. + + + + +Build the package by hand if you prefer. The `manifest.json`: -The manifest `id` (the Teams app id) must be its **own** GUID, distinct from the bot's App ID. `bots[].botId` is the App ID from Step 1. Scopes are lowercase (`personal`, `team`, `groupchat`), and `validDomains` plus an `accentColor` are required. +The manifest `id` (the Teams app id) must be its **own** GUID, distinct from the bot's App ID. `bots[].botId` is the App ID from Step 1. Scopes are lowercase (`personal`, `team`, `groupchat`); `validDomains` and `accentColor` are required; do **not** add a `packageName` key (schema 1.17 rejects it). -Upload the package in Teams (**Apps** → **Manage your apps** → **Upload an app**). Most tenants require admin approval: approve it in the **Teams admin center** under **Manage apps**, where you can also restrict who may install it. +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json", + "manifestVersion": "1.17", + "version": "1.0.0", + "id": "GENERATE-A-FRESH-GUID", + "developer": { "name": "Spacebot", "websiteUrl": "https://example.com", "privacyUrl": "https://example.com", "termsOfUseUrl": "https://example.com" }, + "icons": { "color": "icon-color.png", "outline": "icon-outline.png" }, + "name": { "short": "Spacebot", "full": "Spacebot — AI assistant" }, + "description": { "short": "AI assistant powered by Spacebot.", "full": "Chat with your Spacebot agent in Microsoft Teams." }, + "accentColor": "#6264A7", + "bots": [{ "botId": "YOUR-APP-ID", "scopes": ["personal", "team", "groupchat"], "supportsFiles": false, "isNotificationOnly": false }], + "validDomains": [] +} +``` + +Zip `manifest.json` + `icon-color.png` (192×192) + `icon-outline.png` (32×32) at the **root** of the archive. + + + + +Most tenants require admin approval: approve it in the **Teams admin center** under **Manage apps**, where you can also restrict who may install it. ## Step 4: Configure Spacebot