> = {
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,80 @@ export function AddInstanceCard({
>
)}
+ {platform === "teams" && (
+ <>
+
+
+ App ID
+
+
+ setCredentialInputs({
+ ...credentialInputs,
+ teams_app_id: e.target.value,
+ })
+ }
+ placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ />
+
+
+
+ Client Secret
+
+
+ setCredentialInputs({
+ ...credentialInputs,
+ teams_client_secret: e.target.value,
+ })
+ }
+ placeholder="Your Azure app client secret"
+ />
+
+
+
+ Tenant ID
+
+
+ setCredentialInputs({
+ ...credentialInputs,
+ teams_tenant_id: e.target.value,
+ })
+ }
+ placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ />
+
+ {(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.
+
+ >
+ )}
+ >
+ )}
+
{platform === "signal" && (
<>
@@ -1521,7 +1615,7 @@ function BindingForm({
)}
- {(platform === "discord" || platform === "slack") && (
+ {(platform === "discord" || platform === "slack" || platform === "teams") && (
Channel IDs
diff --git a/interface/src/components/settings/ChannelsSection.tsx b/interface/src/components/settings/ChannelsSection.tsx
index 3061be822..036ca684f 100644
--- a/interface/src/components/settings/ChannelsSection.tsx
+++ b/interface/src/components/settings/ChannelsSection.tsx
@@ -27,6 +27,8 @@ export function ChannelsSection() {
function isDefaultAdd(): boolean {
if (!addingPlatform) return true;
+ // Teams only supports a single default instance; never show the named-instance form.
+ if (addingPlatform === "teams") return true;
return !instances.some(
(inst) => inst.platform === addingPlatform && inst.name === null,
);
diff --git a/interface/src/components/settings/types.ts b/interface/src/components/settings/types.ts
index 0f4d29ba0..ee4d0cd6d 100644
--- a/interface/src/components/settings/types.ts
+++ b/interface/src/components/settings/types.ts
@@ -22,7 +22,8 @@ export type Platform =
| "email"
| "webhook"
| "mattermost"
- | "signal";
+ | "signal"
+ | "teams";
export interface GlobalSettingsSectionProps {
settings: GlobalSettingsResponse | undefined;
diff --git a/interface/src/lib/platformIcons.tsx b/interface/src/lib/platformIcons.tsx
index a79dca881..5bbe281e0 100644
--- a/interface/src/lib/platformIcons.tsx
+++ b/interface/src/lib/platformIcons.tsx
@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { faDiscord, faSlack, faTelegram, faTwitch, faWhatsapp } from "@fortawesome/free-brands-svg-icons";
+import { faDiscord, faSlack, faTelegram, faTwitch, faWhatsapp, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
import { faLink, faEnvelope, faComments, faComment, faServer, faCommentDots } from "@fortawesome/free-solid-svg-icons";
interface PlatformIconProps {
@@ -19,6 +19,7 @@ export function PlatformIcon({ platform, className = "text-ink-faint", size = "1
webhook: faLink,
email: faEnvelope,
mattermost: faServer,
+ teams: faMicrosoft,
whatsapp: faWhatsapp,
signal: faComment,
matrix: faComments,
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/api.rs b/src/api.rs
index 7ff078a3a..b63b8d9f8 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -32,6 +32,7 @@ pub(crate) mod ssh;
mod state;
mod system;
mod tasks;
+mod teams_package;
mod tools;
mod usage;
mod wiki;
diff --git a/src/api/messaging.rs b/src/api/messaging.rs
index 6aad16fe3..d611fdb1c 100644
--- a/src/api/messaging.rs
+++ b/src/api/messaging.rs
@@ -1,6 +1,7 @@
use super::state::ApiState;
use axum::Json;
+use axum::extract::Query;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
@@ -105,6 +106,13 @@ pub(super) struct InstanceCredentials {
mattermost_base_url: Option,
#[serde(default)]
mattermost_token: Option,
+ // Teams credentials
+ #[serde(default)]
+ teams_app_id: Option,
+ #[serde(default)]
+ teams_client_secret: Option,
+ #[serde(default)]
+ teams_tenant_id: Option,
// Signal credentials
#[serde(default)]
signal_http_url: Option,
@@ -796,6 +804,47 @@ pub(super) async fn messaging_status(
enabled: false,
});
+ // Teams status (single-instance only in v1)
+ let _teams_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("teams"))
+ .map(|t| {
+ let has_app_id = t
+ .get("app_id")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_client_secret = t
+ .get("client_secret")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_tenant_id = t
+ .get("tenant_id")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let enabled = t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+ let configured = has_app_id && has_client_secret && has_tenant_id;
+
+ if configured {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "teams",
+ None,
+ true,
+ enabled,
+ );
+ }
+
+ PlatformStatus {
+ configured,
+ enabled: configured && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
// Signal status and instances
let signal_status = doc
.get("messaging")
@@ -1559,6 +1608,41 @@ pub(super) async fn toggle_platform(
}
}
}
+ "teams" => {
+ if let Some(teams_config) = &new_config.messaging.teams
+ && !teams_config.app_id.is_empty()
+ && !teams_config.client_secret.is_empty()
+ && !teams_config.tenant_id.is_empty()
+ {
+ let permissions = {
+ let perms = crate::config::TeamsPermissions::from_config(
+ teams_config,
+ &new_config.bindings,
+ );
+ std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(perms))
+ };
+ let instance_dir = state.instance_dir.load();
+ 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 start teams adapter on toggle");
+ }
+ }
+ Err(error) => {
+ tracing::error!(%error, "failed to build teams adapter on toggle");
+ }
+ }
+ }
+ }
_ => {}
}
}
@@ -1627,7 +1711,15 @@ pub(super) async fn create_messaging_instance(
if !matches!(
platform.as_str(),
- "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal"
+ "discord"
+ | "slack"
+ | "telegram"
+ | "twitch"
+ | "email"
+ | "webhook"
+ | "mattermost"
+ | "signal"
+ | "teams"
) {
return Ok(Json(MessagingInstanceActionResponse {
success: false,
@@ -1671,6 +1763,14 @@ pub(super) async fn create_messaging_instance(
}
}
+ // Teams v1: single bot only — named instances not supported yet
+ if platform.as_str() == "teams" && request.name.is_some() {
+ return Ok(Json(MessagingInstanceActionResponse {
+ success: false,
+ message: "Teams supports a single bot in this version; named instances are not available yet.".to_string(),
+ }));
+ }
+
let config_path = state.config_path.read().await.clone();
let content = tokio::fs::read_to_string(&config_path)
.await
@@ -1841,6 +1941,25 @@ pub(super) async fn create_messaging_instance(
}
}
}
+ "teams" => {
+ let app_id = credentials.teams_app_id.as_deref().unwrap_or("").trim();
+ let client_secret = credentials
+ .teams_client_secret
+ .as_deref()
+ .unwrap_or("")
+ .trim();
+ let tenant_id = credentials.teams_tenant_id.as_deref().unwrap_or("").trim();
+ if app_id.is_empty() || client_secret.is_empty() || tenant_id.is_empty() {
+ return Ok(Json(MessagingInstanceActionResponse {
+ success: false,
+ message: "App ID, client secret, and tenant ID are all required"
+ .to_string(),
+ }));
+ }
+ platform_table["app_id"] = toml_edit::value(app_id);
+ platform_table["client_secret"] = toml_edit::value(client_secret);
+ platform_table["tenant_id"] = toml_edit::value(tenant_id);
+ }
_ => {}
}
if let Some(value) = request.enabled {
@@ -2080,7 +2199,15 @@ pub(super) async fn delete_messaging_instance(
if !matches!(
platform.as_str(),
- "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal"
+ "discord"
+ | "slack"
+ | "telegram"
+ | "twitch"
+ | "email"
+ | "webhook"
+ | "mattermost"
+ | "signal"
+ | "teams"
) {
return Ok(Json(MessagingInstanceActionResponse {
success: false,
@@ -2192,6 +2319,12 @@ pub(super) async fn delete_messaging_instance(
table.remove("group_allowed_users");
table.remove("ignore_stories");
}
+ "teams" => {
+ table.remove("app_id");
+ table.remove("client_secret");
+ table.remove("tenant_id");
+ table.remove("dm_allowed_users");
+ }
_ => {}
}
}
@@ -2300,6 +2433,60 @@ pub(super) async fn delete_messaging_instance(
}))
}
+#[derive(serde::Deserialize)]
+pub(super) struct TeamsPackageQuery {
+ /// The bot App (client) ID — becomes `bots[].botId` in the manifest.
+ app_id: String,
+}
+
+#[utoipa::path(
+ get,
+ path = "/messaging/teams/app-package",
+ params(("app_id" = String, Query, description = "Bot App (client) ID")),
+ responses(
+ (status = 200, description = "Teams app package (zip)", content_type = "application/zip"),
+ (status = 400, description = "Missing or invalid app_id"),
+ (status = 500, description = "Failed to build package"),
+ ),
+ tag = "messaging",
+)]
+pub(super) async fn download_teams_app_package(
+ State(state): State>,
+ Query(query): Query,
+) -> Result {
+ let app_id = query.app_id.trim();
+ if app_id.is_empty() {
+ return Err((
+ axum::http::StatusCode::BAD_REQUEST,
+ "app_id is required".into(),
+ ));
+ }
+
+ let instance_dir = state.instance_dir.load();
+ // `&instance_dir` (a `&Guard>`) coerces to `&Path` via deref,
+ // matching how messaging.rs already uses the guard (it calls `.join()` on it).
+ // Do NOT use `.as_ref()` here — it's ambiguous and won't coerce to `&Path`.
+ let manifest_id = crate::api::teams_package::load_or_create_manifest_id(&instance_dir);
+
+ let bytes =
+ crate::api::teams_package::build_app_package(app_id, &manifest_id).map_err(|error| {
+ tracing::error!(%error, "failed to build teams app package");
+ (
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ "failed to build teams app package".into(),
+ )
+ })?;
+
+ let headers = [
+ (axum::http::header::CONTENT_TYPE, "application/zip"),
+ (
+ axum::http::header::CONTENT_DISPOSITION,
+ "attachment; filename=spacebot-teams-app.zip",
+ ),
+ ];
+ Ok((headers, bytes))
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/api/server.rs b/src/api/server.rs
index a60cb7ec5..bd3eea49d 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -210,6 +210,7 @@ pub fn api_router() -> OpenApiRouter> {
.routes(routes!(messaging::messaging_status))
.routes(routes!(messaging::disconnect_platform))
.routes(routes!(messaging::toggle_platform))
+ .routes(routes!(messaging::download_teams_app_package))
.routes(routes!(
messaging::create_messaging_instance,
messaging::delete_messaging_instance
diff --git a/src/api/teams_package.rs b/src/api/teams_package.rs
new file mode 100644
index 000000000..f2eb90e15
--- /dev/null
+++ b/src/api/teams_package.rs
@@ -0,0 +1,198 @@
+//! Generates a sideloadable Microsoft Teams app package (manifest + icons, zipped)
+//! so operators don't have to hand-write the manifest. The manifest gotchas
+//! (schema 1.17, distinct app id, lowercase scopes, no packageName, required
+//! accentColor/validDomains) are baked in; default icons are the Spacebot logo.
+
+use std::io::{Cursor, Write as _};
+use std::path::Path;
+
+use image::imageops::FilterType;
+use zip::CompressionMethod;
+use zip::write::SimpleFileOptions;
+
+/// The Spacebot logo, bundled at compile time. 384x384 RGBA PNG.
+const SPACEBOT_LOGO: &[u8] = include_bytes!("../../interface/public/ball.png");
+
+/// Build the Teams app manifest. `app_id` is the bot's App (client) ID
+/// (`bots[].botId`); `manifest_id` is the distinct Teams app id (`id`).
+pub fn teams_manifest_json(app_id: &str, manifest_id: &str) -> serde_json::Value {
+ serde_json::json!({
+ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
+ "manifestVersion": "1.17",
+ "version": "1.0.0",
+ "id": manifest_id,
+ "developer": {
+ "name": "Spacebot",
+ "websiteUrl": "https://github.com/spacedriveapp/spacebot",
+ "privacyUrl": "https://github.com/spacedriveapp/spacebot",
+ "termsOfUseUrl": "https://github.com/spacedriveapp/spacebot"
+ },
+ "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": app_id,
+ "scopes": ["personal", "team", "groupchat"],
+ "supportsFiles": false,
+ "isNotificationOnly": false
+ }],
+ "validDomains": []
+ })
+}
+// NOTE: do NOT add a `permissions` key. The manifest validated against real
+// Teams (docs/design-docs/teams-setup.md) has no `permissions` field; schema
+// 1.17 rejects undefined/extra properties, and `permissions` is deprecated.
+
+/// Render the two Teams icons from the bundled logo: color 192x192 (the logo
+/// resized) and outline 32x32 (a white silhouette on transparent, per Teams'
+/// outline-icon requirement).
+pub fn render_default_icons() -> anyhow::Result<(Vec, Vec)> {
+ let logo = image::load_from_memory(SPACEBOT_LOGO)?;
+
+ let mut color = Vec::new();
+ logo.resize_exact(192, 192, FilterType::Lanczos3)
+ .write_to(&mut Cursor::new(&mut color), image::ImageFormat::Png)?;
+
+ // Outline: resize to 32x32, then make every non-transparent pixel white
+ // (Teams renders the outline icon monochrome on a transparent background).
+ let mut outline_img = logo.resize_exact(32, 32, FilterType::Lanczos3).to_rgba8();
+ for px in outline_img.pixels_mut() {
+ let alpha = px.0[3];
+ px.0 = [255, 255, 255, alpha];
+ }
+ let mut outline = Vec::new();
+ image::DynamicImage::ImageRgba8(outline_img)
+ .write_to(&mut Cursor::new(&mut outline), image::ImageFormat::Png)?;
+
+ Ok((color, outline))
+}
+
+/// Build the `.zip` package: manifest.json + the two icons at the archive root.
+pub fn build_app_package(app_id: &str, manifest_id: &str) -> anyhow::Result> {
+ let manifest = serde_json::to_vec_pretty(&teams_manifest_json(app_id, manifest_id))?;
+ let (color, outline) = render_default_icons()?;
+
+ let mut cursor = Cursor::new(Vec::new());
+ let mut zip = zip::ZipWriter::new(&mut cursor);
+ let opts = SimpleFileOptions::default()
+ .compression_method(CompressionMethod::Deflated)
+ .unix_permissions(0o644);
+
+ for (name, bytes) in [
+ ("manifest.json", manifest.as_slice()),
+ ("icon-color.png", color.as_slice()),
+ ("icon-outline.png", outline.as_slice()),
+ ] {
+ zip.start_file(name, opts)?;
+ zip.write_all(bytes)?;
+ }
+ zip.finish()?;
+ Ok(cursor.into_inner())
+}
+
+/// Read the persisted Teams app `id`, or generate+persist a fresh GUID. Keeping
+/// it stable means re-downloading produces the same `id`, so re-uploading the
+/// package updates the existing Teams app instead of creating a duplicate.
+pub fn load_or_create_manifest_id(instance_dir: &Path) -> String {
+ let path = instance_dir.join("teams_manifest_id.json");
+ if let Ok(contents) = std::fs::read_to_string(&path)
+ && let Ok(v) = serde_json::from_str::(&contents)
+ && let Some(id) = v.get("manifest_id").and_then(|x| x.as_str())
+ && !id.is_empty()
+ {
+ return id.to_string();
+ }
+ let id = uuid::Uuid::new_v4().to_string();
+ let tmp = path.with_extension("json.tmp");
+ let json = serde_json::json!({ "manifest_id": id }).to_string();
+ if std::fs::write(&tmp, &json).is_ok() {
+ if let Err(e) = std::fs::rename(&tmp, &path) {
+ tracing::warn!(%e, ?path, "teams manifest-id sidecar: rename failed");
+ }
+ } else {
+ tracing::warn!(?path, "teams manifest-id sidecar: write failed");
+ }
+ id
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Read as _;
+
+ #[test]
+ fn manifest_has_required_fields_and_no_packagename() {
+ let m = teams_manifest_json(
+ "00000000-0000-0000-0000-000000000001",
+ "ffffffff-0000-0000-0000-000000000002",
+ );
+ assert_eq!(m["manifestVersion"], "1.17");
+ assert_eq!(m["id"], "ffffffff-0000-0000-0000-000000000002");
+ // id MUST differ from botId.
+ assert_ne!(m["id"], m["bots"][0]["botId"]);
+ assert_eq!(
+ m["bots"][0]["botId"],
+ "00000000-0000-0000-0000-000000000001"
+ );
+ assert_eq!(m["bots"][0]["scopes"][0], "personal");
+ assert_eq!(m["bots"][0]["scopes"][1], "team");
+ assert_eq!(m["bots"][0]["scopes"][2], "groupchat");
+ assert!(m.get("accentColor").is_some());
+ assert!(m.get("validDomains").is_some());
+ assert!(
+ m.get("packageName").is_none(),
+ "schema 1.17 rejects packageName"
+ );
+ assert_eq!(m["icons"]["color"], "icon-color.png");
+ assert_eq!(m["icons"]["outline"], "icon-outline.png");
+ }
+
+ #[test]
+ fn icons_render_at_required_dimensions() {
+ let (color, outline) = render_default_icons().expect("icons render");
+ let c = image::load_from_memory(&color).expect("color png");
+ assert_eq!((c.width(), c.height()), (192, 192));
+ let o = image::load_from_memory(&outline).expect("outline png");
+ assert_eq!((o.width(), o.height()), (32, 32));
+ }
+
+ #[test]
+ fn package_zip_contains_the_three_root_files() {
+ let bytes = build_app_package(
+ "00000000-0000-0000-0000-000000000001",
+ "ffffffff-0000-0000-0000-000000000002",
+ )
+ .expect("package builds");
+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip opens");
+ let names: Vec = (0..zip.len())
+ .map(|i| zip.by_index(i).unwrap().name().to_string())
+ .collect();
+ assert!(names.contains(&"manifest.json".to_string()));
+ assert!(names.contains(&"icon-color.png".to_string()));
+ assert!(names.contains(&"icon-outline.png".to_string()));
+ // manifest.json parses and round-trips the ids.
+ let mut mf = zip.by_name("manifest.json").unwrap();
+ let mut s = String::new();
+ mf.read_to_string(&mut s).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
+ assert_eq!(
+ parsed["bots"][0]["botId"],
+ "00000000-0000-0000-0000-000000000001"
+ );
+ }
+
+ #[test]
+ fn manifest_id_sidecar_is_stable() {
+ let dir = std::env::temp_dir().join(format!("teams-pkg-test-{}", uuid::Uuid::new_v4()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let a = load_or_create_manifest_id(&dir);
+ let b = load_or_create_manifest_id(&dir);
+ assert_eq!(a, b, "second call reuses the persisted id");
+ assert_eq!(a.len(), 36, "looks like a GUID");
+ std::fs::remove_dir_all(&dir).ok();
+ }
+}
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..97c0e34a6
--- /dev/null
+++ b/src/messaging/teams.rs
@@ -0,0 +1,3077 @@
+//! 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
+ && !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.
+// 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,
+ 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()