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..24db4290e 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,125 @@ 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>,
+ /// 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,
+}
+
+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 +679,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..33bc754a4 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,53 @@ 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
+ && !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");
+ }
+ }
+ }
+
+ // 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/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..e6744f95a 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,42 @@ pub fn resolve_broadcast_target(channel: &ChannelInfo) -> Option {
+ // 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()
+ .and_then(|meta| meta.get("teams_conversation_id"))
+ .and_then(json_value_to_string)
+ {
+ return Some(BroadcastTarget {
+ adapter: adapter.to_string(),
+ target: conv_id,
+ });
+ }
+
+ let bare_conv_id = channel
+ .id
+ .strip_prefix("teams:")
+ .unwrap_or(&channel.id)
+ .to_string();
+
+ if bare_conv_id.is_empty() {
+ return None;
+ }
+
+ return Some(BroadcastTarget {
+ adapter: adapter.to_string(),
+ 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 +252,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 +386,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 +634,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 +653,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;
}
@@ -606,6 +665,18 @@ fn parse_named_instance_target(parts: &[&str]) -> 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() =>
@@ -1098,4 +1169,128 @@ 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(),
+ })
+ );
+ }
+
+ /// 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_instance_segment_kept_in_conversation_id() {
+ let parsed = parse_delivery_target("teams:prod:conv-ch-999");
+ assert_eq!(
+ parsed,
+ Some(super::BroadcastTarget {
+ 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(),
+ })
+ );
+ }
+
+ /// 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(),
+ })
+ );
+ }
+
+ /// Multi-colon ids are preserved verbatim; no instance is inferred.
+ #[test]
+ 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".to_string(),
+ target: "prod: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(),
+ })
+ );
+ }
+
+ /// 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_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".to_string(),
+ target: "a:1-conv_abc_def".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..db9fa42c3
--- /dev/null
+++ b/src/messaging/teams.rs
@@ -0,0 +1,3109 @@
+//! 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