diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md new file mode 100644 index 00000000..1ffbcd1e --- /dev/null +++ b/docs/msteams-selfhosted.md @@ -0,0 +1,304 @@ +# Microsoft Teams Setup (Self-Hosted) + +Connect a Microsoft Teams bot to OpenAB via the Custom Gateway using a self-hosted Docker Compose stack. + +``` +Teams (Bot Framework) ──POST──▶ Gateway (:8080) ◀──WebSocket── OAB Pod + (OAB connects out) + ◀──REST──── (Bot Framework reply) +``` + +## Prerequisites + +- Docker and Docker Compose installed +- A Microsoft 365 / Azure AD account with permission to register apps and create Azure Bot resources +- A public HTTPS URL for the gateway (Cloudflare Tunnel, ngrok, Tailscale Funnel, etc.) — Bot Framework requires HTTPS endpoints + +## 1. Register an Azure AD Application + +1. Go to [Azure Portal → App registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration** +2. Name: `openab-teams-bot` (or anything you like) +3. **Supported account types**: + - **Single tenant** — only your organization can use the bot (most common for internal use) + - **Multitenant** — anyone with a Microsoft 365 account can install +4. Leave **Redirect URI** empty → Register + +After creation, copy from the **Overview** page: + +- **Application (client) ID** → `TEAMS_APP_ID` +- **Directory (tenant) ID** → needed for `TEAMS_OAUTH_ENDPOINT` if Single tenant + +Then go to **Certificates & secrets** → **New client secret** → copy the **Value** (not the Secret ID) → `TEAMS_APP_SECRET`. + +> Client secrets are only shown once. Store it before leaving the page. + +## 2. Create an Azure Bot Resource + +1. Azure Portal → **Create a resource** → search **Azure Bot** → Create +2. **Bot handle**: pick a unique name (e.g. `openab`) +3. **Subscription / Resource group**: pick yours +4. **Pricing tier**: F0 (free) is fine for testing +5. **Microsoft App ID**: + - **Type of App**: must match what you picked in step 1 (`Single Tenant` or `Multi Tenant`) + - **Creation type**: **Use existing app registration** + - **App ID**: paste the `TEAMS_APP_ID` from step 1 + - **App tenant ID** (Single tenant only): paste your tenant ID +6. Review + Create + +After deployment, open the bot: + +- **Configuration** → **Messaging endpoint**: `https:///webhook/teams` +- **Channels** → click **Microsoft Teams** → accept terms → save + +## 3. Build a Teams App Manifest + +Bot Framework only delivers messages once a Teams app installs your bot. + +### Option A — Teams Developer Portal (UI) + +In [Teams Developer Portal](https://dev.teams.microsoft.com) → **Apps** → **New app**: + +1. **Basic information** → fill name, description, developer info +2. **App features** → **Bot** → **Create new bot** → select **Use existing bot ID** → paste `TEAMS_APP_ID` +3. Pick the scopes the bot needs: + - **Personal** — 1:1 chat + - **Team** — channel chat (must be @mentioned) + - **Group chat** — multi-person DMs +4. **Publish** → **Publish to your org** (single tenant) or sideload via **Apps for your org** + +### Option B — Hand-rolled manifest.json + +Create `manifest.json` next to two icons (`outline.png` — transparent 32×32 white, `color.png` — 192×192 colored), zip them, and in Teams: **Apps → Manage your apps → Upload a custom app**. + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.25/MicrosoftTeams.schema.json", + "manifestVersion": "1.25", + "version": "1.0.0", + "id": "", + "developer": { + "name": "", + "websiteUrl": "https://example.com", + "privacyUrl": "https://example.com/privacy", + "termsOfUseUrl": "https://example.com/terms" + }, + "name": { + "short": "", + "full": "" + }, + "description": { + "short": "", + "full": "" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#ffffff", + "bots": [ + { + "botId": "", + "scopes": ["personal", "team", "groupChat"], + "isNotificationOnly": false, + "supportsFiles": false + } + ], + "validDomains": [] +} +``` + +Notes: + +- `id` is the **Teams app id** — generate a fresh UUID v4 (`uuidgen`). It is **not** the same as `botId`. +- `botId` is the **Microsoft App (Bot) id** from step 1 (the value you put in `TEAMS_APP_ID`). +- The three `developer.*` URLs are required by the schema. They can point at your GitHub repo / privacy page / license — they just have to resolve. + +> If your tenant requires admin approval, an admin must approve the published app in Teams Admin Center → Manage apps. + +## 4. Self-Hosted Deployment (Docker Compose) + +Drop these three files into a project directory and run `docker compose up -d`. + +### `.env` + +```env +# From Azure AD app registration (step 1) +TEAMS_APP_ID="" +TEAMS_APP_SECRET="" + +# Single tenant: must point at your tenant +# Multi tenant: leave this line out (uses default) +TEAMS_OAUTH_ENDPOINT="https://login.microsoftonline.com//oauth2/v2.0/token" + +# Only needed if you use the Cloudflare Tunnel service below. +# Skip this line if you expose the gateway via a different reverse proxy. +TUNNEL_TOKEN="" + +RUST_LOG=info +``` + +> `.env` should be `.gitignore`d — it holds your bot secret. + +### `docker-compose.yaml` + +```yaml +services: + gateway: + image: ghcr.io/openabdev/openab-gateway:latest + container_name: gateway + env_file: + - .env + ports: + - 8080:8080 + + openab: + image: ghcr.io/openabdev/openab:latest + container_name: openab + volumes: + - ./config.toml:/etc/openab/config.toml + - ./data:/home/agent + env_file: + - .env + depends_on: + - gateway + + # Optional — only include this service if you want to use Cloudflare Tunnel. + # Drop this block if you reverse-proxy gateway:8080 some other way. + tunnels: + image: cloudflare/cloudflared:latest + command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN} + env_file: + - .env + depends_on: + - gateway + - openab +``` + +### `config.toml` + +```toml +[agent] +command = "kiro-cli" +args = ["acp", "--trust-all-tools"] +working_dir = "/home/agent" + +[pool] +max_sessions = 10 +session_ttl_hours = 24 + +[reactions] +enabled = true + +[gateway] +url = "ws://gateway:8080/ws" +platform = "teams" +``` + +### Start the stack + +```bash +docker compose up -d +docker compose logs -f gateway openab +``` + +## 5. Public HTTPS Exposure + +Bot Framework needs to reach the gateway over HTTPS. Any reverse proxy works — pick whichever fits your setup. + +### Option A — Cloudflare Tunnel + +In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/), open your tunnel and add a public hostname: + +| Field | Value | +|---|---| +| Subdomain / Hostname | `openab-bot` (or anything) | +| Path | `/webhook/teams` | +| Service type | `HTTP` | +| URL | `gateway:8080` | + +### Option B — ngrok / Tailscale Funnel / other reverse proxy + +```bash +# ngrok example +ngrok http 8080 +# → https://.ngrok-free.app/webhook/teams +``` + +Drop the `tunnels` service and the `TUNNEL_TOKEN` line in `.env`; just expose `gateway:8080` to the internet however you prefer (k8s ingress, Caddy, nginx + Let's Encrypt, Tailscale Funnel, etc.). + +### Point Bot Framework at your endpoint + +Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `https:///webhook/teams` + +## 6. Install the Bot in Teams + +1. **Apps** → **Manage your apps** → **Built for your org** → find your app → **Add** +2. For personal chat: open the app, start chatting +3. For a channel: click the app → **Add to a team** → choose the team → use `@` in conversation + +## Supported Features + +- **1:1 personal chat** — direct message the bot, get an agent response +- **Channel chat** — bot responds when @mentioned +- **Group chat** — same @mention gating +- **JWT validation** — every webhook is verified against Microsoft's public JWKS +- **Markdown rendering** — replies are sent with `textFormat: "markdown"` +- **Tenant allowlist** — set `TEAMS_ALLOWED_TENANTS=,` to restrict which tenants can talk to the bot + +## Current Limitations + +- **Reactions** — status reactions (👀 / 🤔 / ⚡ / 🆗) are silently dropped for Teams replies +- **Thread replies** — all messages in a personal chat or channel share one agent session +- **Streaming edits** — replies are sent as one final message, not progressively edited + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `TEAMS_APP_ID` | Yes | — | Azure AD application (client) ID | +| `TEAMS_APP_SECRET` | Yes | — | Azure AD client secret value | +| `TEAMS_OAUTH_ENDPOINT` | Single tenant: Yes | `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token` | Override for single tenant bots | +| `TEAMS_OPENID_METADATA` | No | `https://login.botframework.com/v1/.well-known/openidconfiguration` | OpenID metadata for JWT validation | +| `TEAMS_ALLOWED_TENANTS` | No | (allow all) | Comma-separated tenant IDs | +| `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | URL path the gateway listens on | + +## Troubleshooting + +**401 Unauthorized when bot tries to reply** + +- Almost always means OAuth endpoint vs. app type mismatch. +- Single tenant bot → set `TEAMS_OAUTH_ENDPOINT=https://login.microsoftonline.com//oauth2/v2.0/token` +- Multi tenant bot → leave default, but verify `TEAMS_APP_ID` and `TEAMS_APP_SECRET` are correct. + +**`teams: no service_url for conversation` in gateway logs** + +- Gateway was restarted and the in-memory cache was cleared. Have the user send another message. +- Or the webhook never arrived — check Bot Framework webhook URL points at the right gateway. + +**`teams JWT validation failed` in gateway logs** + +- The gateway auto-refreshes JWKS on miss, so this usually resolves on retry. +- If it persists, check `TEAMS_OPENID_METADATA` is reachable from the gateway container. + +**Webhook returns 200 but no agent response** + +Check `docker compose logs gateway openab` and look for the trace: +1. `teams → gateway` (gateway received webhook) +2. `processing message channel_platform=teams` (OAB picked up the event) +3. `sending reply to gateway platform=teams` (OAB sent the reply over WS) +4. `gateway → teams` (gateway calling Bot Framework REST API) +5. `teams activity sent` (success) or `teams send error` (failure) + +Whichever step is missing tells you where the break is. + +**Bot doesn't appear when @mentioning in a channel** + +- The Teams app must be installed in the team (Apps → Built for your org → Add to a team). +- If your tenant blocks third-party apps, an admin must approve in Teams Admin Center → Manage apps. + +## References + +- [Bot Framework REST API](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference) +- [Azure Bot Service authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication) +- [Teams Developer Portal](https://dev.teams.microsoft.com) +- [Teams app manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index d430ff4a..98291ed9 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -19,6 +19,7 @@ chrono = { version = "0.4", features = ["serde"] } hmac = "0.12" sha2 = "0.10" base64 = "0.22" +jsonwebtoken = "9" [dev-dependencies] wiremock = "0.6" diff --git a/gateway/src/adapters/mod.rs b/gateway/src/adapters/mod.rs index 4e4ed969..61c8f110 100644 --- a/gateway/src/adapters/mod.rs +++ b/gateway/src/adapters/mod.rs @@ -1,2 +1,3 @@ pub mod line; +pub mod teams; pub mod telegram; diff --git a/gateway/src/adapters/teams.rs b/gateway/src/adapters/teams.rs new file mode 100644 index 00000000..d7b5433e --- /dev/null +++ b/gateway/src/adapters/teams.rs @@ -0,0 +1,808 @@ +use crate::schema::*; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{debug, error, info, warn}; + +// --- Bot Framework activity types --- + +#[allow(dead_code)] // Bot Framework schema fields — needed for future features +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Activity { + #[serde(rename = "type")] + pub activity_type: String, + pub id: Option, + pub timestamp: Option, + pub service_url: Option, + pub channel_id: Option, + pub from: Option, + pub conversation: Option, + pub text: Option, + pub tenant: Option, + pub channel_data: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelAccount { + pub id: Option, + pub name: Option, + pub aad_object_id: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversationAccount { + pub id: Option, + pub conversation_type: Option, + pub is_group: Option, + pub tenant_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TenantInfo { + pub id: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelData { + pub tenant: Option, +} + +impl Activity { + /// Resolve tenant id from any of the locations Teams may put it. + pub fn resolved_tenant_id(&self) -> Option<&str> { + self.tenant + .as_ref() + .and_then(|t| t.id.as_deref()) + .or_else(|| { + self.channel_data + .as_ref() + .and_then(|c| c.tenant.as_ref()) + .and_then(|t| t.id.as_deref()) + }) + .or_else(|| { + self.conversation + .as_ref() + .and_then(|c| c.tenant_id.as_deref()) + }) + } +} + +// --- OpenID configuration --- + +#[derive(Debug, Deserialize)] +struct OpenIdConfig { + jwks_uri: String, +} + +#[derive(Debug, Deserialize)] +struct JwksResponse { + keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct JwkKey { + kid: Option, + n: String, + e: String, + kty: String, + #[serde(default)] + endorsements: Vec, +} + +// --- OAuth token --- + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: u64, +} + +struct CachedToken { + token: String, + expires_at: std::time::Instant, +} + +// --- Teams adapter config --- + +pub struct TeamsConfig { + pub app_id: String, + pub app_secret: String, + pub oauth_endpoint: String, + pub openid_metadata: String, + pub allowed_tenants: Vec, +} + +impl TeamsConfig { + pub fn from_env() -> Option { + let app_id = std::env::var("TEAMS_APP_ID").ok()?; + let app_secret = std::env::var("TEAMS_APP_SECRET").ok()?; + Some(Self { + app_id, + app_secret, + oauth_endpoint: std::env::var("TEAMS_OAUTH_ENDPOINT").unwrap_or_else(|_| { + "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token".into() + }), + openid_metadata: std::env::var("TEAMS_OPENID_METADATA").unwrap_or_else(|_| { + "https://login.botframework.com/v1/.well-known/openidconfiguration".into() + }), + allowed_tenants: std::env::var("TEAMS_ALLOWED_TENANTS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }) + } +} + +// --- Teams adapter state --- + +pub struct TeamsAdapter { + config: TeamsConfig, + client: reqwest::Client, + token_cache: RwLock>, + jwks_cache: RwLock, std::time::Instant)>>, +} + +const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600); +const TOKEN_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(300); + +impl TeamsAdapter { + pub fn new(config: TeamsConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + token_cache: RwLock::new(None), + jwks_cache: RwLock::new(None), + } + } + + /// Get a valid OAuth bearer token, refreshing if needed. + async fn get_token(&self) -> anyhow::Result { + // Check cache + { + let cache = self.token_cache.read().await; + if let Some(ref cached) = *cache { + if cached.expires_at > std::time::Instant::now() + TOKEN_REFRESH_MARGIN { + return Ok(cached.token.clone()); + } + } + } + + // Fetch new token + let resp: TokenResponse = self + .client + .post(&self.config.oauth_endpoint) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.config.app_id), + ("client_secret", &self.config.app_secret), + ("scope", "https://api.botframework.com/.default"), + ]) + .send() + .await? + .json() + .await?; + + let token = resp.access_token.clone(); + *self.token_cache.write().await = Some(CachedToken { + token: resp.access_token, + expires_at: std::time::Instant::now() + std::time::Duration::from_secs(resp.expires_in), + }); + info!("teams OAuth token refreshed"); + Ok(token) + } + + /// Fetch and cache JWKS signing keys from Microsoft's OpenID metadata. + async fn get_jwks(&self) -> anyhow::Result> { + { + let cache = self.jwks_cache.read().await; + if let Some((ref keys, fetched_at)) = *cache { + if fetched_at.elapsed() < JWKS_CACHE_TTL { + return Ok(keys.clone()); + } + } + } + + let config: OpenIdConfig = self + .client + .get(&self.config.openid_metadata) + .send() + .await? + .json() + .await?; + + let jwks: JwksResponse = self + .client + .get(&config.jwks_uri) + .send() + .await? + .json() + .await?; + + let keys = jwks.keys; + *self.jwks_cache.write().await = Some((keys.clone(), std::time::Instant::now())); + info!(count = keys.len(), "teams JWKS keys refreshed"); + Ok(keys) + } + + /// Force-refresh JWKS keys, bypassing cache TTL. Called on cache miss (kid not found). + async fn refresh_jwks(&self) -> anyhow::Result> { + // Invalidate cache so get_jwks fetches fresh + *self.jwks_cache.write().await = None; + self.get_jwks().await + } + + /// Validate the JWT bearer token from an inbound Bot Framework request. + /// Checks: signature, issuer, audience, expiry, serviceUrl claim, and channel endorsements. + pub async fn validate_jwt(&self, auth_header: &str, activity: &Activity) -> anyhow::Result<()> { + let token = auth_header + .strip_prefix("Bearer ") + .ok_or_else(|| anyhow::anyhow!("missing Bearer prefix"))?; + + // Decode header to get kid + let header = jsonwebtoken::decode_header(token)?; + let kid = header + .kid + .ok_or_else(|| anyhow::anyhow!("no kid in JWT header"))?; + + let keys = self.get_jwks().await?; + let key = match keys.iter().find(|k| k.kid.as_deref() == Some(&kid)) { + Some(k) => k.clone(), + None => { + // Cache miss: Microsoft may have rotated keys. Force refresh and retry. + let refreshed = self.refresh_jwks().await?; + refreshed + .into_iter() + .find(|k| k.kid.as_deref() == Some(&kid)) + .ok_or_else(|| anyhow::anyhow!("no matching JWK for kid={kid} after refresh"))? + } + }; + + if key.kty != "RSA" { + anyhow::bail!("unsupported key type: {}", key.kty); + } + + // B2: Validate channel endorsements — key must endorse the activity's channelId + let channel_id = activity.channel_id.as_deref() + .ok_or_else(|| anyhow::anyhow!("activity missing channelId"))?; + if key.endorsements.is_empty() { + anyhow::bail!("JWK has no endorsements — cannot verify channelId={channel_id}"); + } + if !key.endorsements.iter().any(|e| e == channel_id) { + anyhow::bail!( + "JWK endorsements {:?} do not include channelId={channel_id}", + key.endorsements + ); + } + + let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e)?; + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[&self.config.app_id]); + // Bot Framework tokens can use RS256 or RS384 + validation.algorithms = vec![Algorithm::RS256, Algorithm::RS384]; + // Bot Framework issuer per auth spec + validation.set_issuer(&["https://api.botframework.com"]); + validation.validate_aud = true; + validation.validate_exp = true; + validation.validate_nbf = false; + + let token_data = decode::(token, &decoding_key, &validation)?; + + // B1: Validate serviceUrl claim matches activity's serviceUrl + let activity_service_url = activity.service_url.as_deref() + .ok_or_else(|| anyhow::anyhow!("activity missing serviceUrl"))?; + let token_service_url = token_data.claims.get("serviceurl") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("JWT missing serviceurl claim"))?; + if token_service_url != activity_service_url { + anyhow::bail!( + "serviceUrl mismatch: token={token_service_url}, activity={activity_service_url}" + ); + } + + Ok(()) + } + + /// Check tenant allowlist. + fn check_tenant(&self, activity: &Activity) -> bool { + if self.config.allowed_tenants.is_empty() { + return true; + } + activity + .resolved_tenant_id() + .is_some_and(|tid| self.config.allowed_tenants.iter().any(|a| a == tid)) + } + + /// Send a reply via Bot Framework REST API. + pub async fn send_activity( + &self, + service_url: &str, + conversation_id: &str, + text: &str, + reply_to_id: Option<&str>, + ) -> anyhow::Result { + let token = self.get_token().await?; + let url = format!( + "{}v3/conversations/{}/activities", + ensure_trailing_slash(service_url), + conversation_id + ); + + let mut body = serde_json::json!({ + "type": "message", + "from": { "id": &self.config.app_id }, + "text": text, + "textFormat": "markdown", + }); + if let Some(id) = reply_to_id { + body["replyToId"] = serde_json::Value::String(id.to_string()); + } + + let resp = self + .client + .post(&url) + .bearer_auth(&token) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Bot Framework API error {status}: {body}"); + } + + let result: serde_json::Value = resp.json().await?; + Ok(result["id"].as_str().unwrap_or("").to_string()) + } + + /// Edit an existing activity (for streaming updates). + pub async fn update_activity( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + text: &str, + ) -> anyhow::Result<()> { + let token = self.get_token().await?; + let url = format!( + "{}v3/conversations/{}/activities/{}", + ensure_trailing_slash(service_url), + conversation_id, + activity_id + ); + + let body = serde_json::json!({ + "type": "message", + "from": { "id": &self.config.app_id }, + "text": text, + }); + + let resp = self + .client + .put(&url) + .bearer_auth(&token) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Bot Framework update error {status}: {body}"); + } + Ok(()) + } +} + +fn ensure_trailing_slash(url: &str) -> String { + if url.ends_with('/') { + url.to_string() + } else { + format!("{url}/") + } +} + +// --- Webhook handler --- + +pub async fn webhook( + State(state): State>, + headers: HeaderMap, + body: String, +) -> StatusCode { + let teams = match &state.teams { + Some(t) => t, + None => return StatusCode::NOT_FOUND, + }; + + // Extract auth header early (before parsing activity) + let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(h) => h.to_string(), + None => { + warn!("teams webhook: missing authorization header"); + return StatusCode::UNAUTHORIZED; + } + }; + + // Parse activity first (needed for JWT serviceUrl + endorsements validation) + let activity: Activity = match serde_json::from_str(&body) { + Ok(a) => a, + Err(e) => { + warn!(error = %e, "teams: invalid activity JSON"); + return StatusCode::BAD_REQUEST; + } + }; + + // JWT validation (with activity context for serviceUrl + channelId checks) + if let Err(e) = teams.validate_jwt(&auth_header, &activity).await { + warn!(error = %e, "teams JWT validation failed"); + return StatusCode::UNAUTHORIZED; + } + + // Only handle message activities + if activity.activity_type != "message" { + debug!(activity_type = %activity.activity_type, "teams: ignoring non-message activity"); + return StatusCode::OK; + } + + // Tenant check + if !teams.check_tenant(&activity) { + let tid = activity.resolved_tenant_id().unwrap_or("unknown"); + warn!(tenant = tid, "teams: tenant not in allowlist"); + return StatusCode::FORBIDDEN; + } + + let text = match activity.text.as_deref() { + Some(t) if !t.trim().is_empty() => t.trim(), + _ => return StatusCode::OK, + }; + + let conversation_id = activity + .conversation + .as_ref() + .and_then(|c| c.id.as_deref()) + .unwrap_or(""); + let conversation_type = activity + .conversation + .as_ref() + .and_then(|c| c.conversation_type.as_deref()) + .unwrap_or("personal"); + let service_url = activity.service_url.as_deref().unwrap_or(""); + let sender_id = activity + .from + .as_ref() + .and_then(|f| f.id.as_deref()) + .unwrap_or(""); + let sender_name = activity + .from + .as_ref() + .and_then(|f| f.name.as_deref()) + .unwrap_or("Unknown"); + let activity_id = activity.id.as_deref().unwrap_or(""); + + // B3: Guard against empty service_url — replies will fail without it + if service_url.is_empty() { + warn!("teams: activity missing service_url, cannot route replies"); + return StatusCode::OK; + } + + let event = GatewayEvent::new( + "teams", + ChannelInfo { + id: conversation_id.to_string(), + channel_type: conversation_type.to_string(), + thread_id: None, // Teams conversations don't have sub-threads in the same way + }, + SenderInfo { + id: sender_id.to_string(), + name: sender_name.to_string(), + display_name: sender_name.to_string(), + is_bot: false, + }, + text, + activity_id, + vec![], // Teams @mentions parsing deferred to future PR + ); + + // Store service_url for reply routing + state.teams_service_urls.lock().await.insert( + conversation_id.to_string(), + (service_url.to_string(), std::time::Instant::now()), + ); + + let json = serde_json::to_string(&event).unwrap(); + let tenant_id = activity.resolved_tenant_id().unwrap_or(""); + info!( + conversation = conversation_id, + sender = sender_name, + tenant = tenant_id, + service_url = service_url, + "teams → gateway" + ); + let _ = state.event_tx.send(json); + + StatusCode::OK +} + +// --- Reply handler --- + +pub async fn handle_reply( + reply: &GatewayReply, + teams: &TeamsAdapter, + service_urls: &tokio::sync::Mutex< + std::collections::HashMap, + >, +) { + // Reactions are not supported on Teams — silently ignore + if reply.command.as_deref() == Some("add_reaction") + || reply.command.as_deref() == Some("remove_reaction") + { + return; + } + + let service_url = { + let mut urls = service_urls.lock().await; + match urls.get_mut(&reply.channel.id) { + Some((url, ts)) => { + // Refresh timestamp on reply to prevent TTL expiry during active conversations + *ts = std::time::Instant::now(); + url.clone() + } + None => { + error!(conversation = %reply.channel.id, "teams: no service_url for conversation"); + return; + } + } + }; + + let reply_to_id = if reply.reply_to.is_empty() { + None + } else { + Some(reply.reply_to.as_str()) + }; + + info!(conversation = %reply.channel.id, "gateway → teams"); + match teams + .send_activity( + &service_url, + &reply.channel.id, + &reply.content.text, + reply_to_id, + ) + .await + { + Ok(id) => debug!(activity_id = %id, "teams activity sent"), + Err(e) => error!(error = %e, "teams send error"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- ensure_trailing_slash --- + + #[test] + fn trailing_slash_adds_when_missing() { + assert_eq!( + ensure_trailing_slash("https://example.com"), + "https://example.com/" + ); + } + + #[test] + fn trailing_slash_keeps_when_present() { + assert_eq!( + ensure_trailing_slash("https://example.com/"), + "https://example.com/" + ); + } + + #[test] + fn trailing_slash_empty_string() { + assert_eq!(ensure_trailing_slash(""), "/"); + } + + // --- check_tenant --- + + fn make_config(tenants: Vec<&str>) -> TeamsConfig { + TeamsConfig { + app_id: "test-app".into(), + app_secret: "test-secret".into(), + oauth_endpoint: "https://example.com/token".into(), + openid_metadata: "https://example.com/openid".into(), + allowed_tenants: tenants.into_iter().map(|s| s.to_string()).collect(), + } + } + + fn make_activity_with_tenant(tenant_id: Option<&str>) -> Activity { + Activity { + activity_type: "message".into(), + id: Some("act1".into()), + timestamp: None, + service_url: Some("https://smba.trafficmanager.net/".into()), + channel_id: Some("msteams".into()), + from: None, + conversation: None, + text: Some("hello".into()), + tenant: tenant_id.map(|id| TenantInfo { + id: Some(id.into()), + }), + channel_data: None, + } + } + + #[test] + fn tenant_allowed_when_list_empty() { + let adapter = TeamsAdapter::new(make_config(vec![])); + let activity = make_activity_with_tenant(Some("any-tenant")); + assert!(adapter.check_tenant(&activity)); + } + + #[test] + fn tenant_allowed_when_in_list() { + let adapter = TeamsAdapter::new(make_config(vec!["tenant-a", "tenant-b"])); + let activity = make_activity_with_tenant(Some("tenant-b")); + assert!(adapter.check_tenant(&activity)); + } + + #[test] + fn tenant_rejected_when_not_in_list() { + let adapter = TeamsAdapter::new(make_config(vec!["tenant-a"])); + let activity = make_activity_with_tenant(Some("tenant-x")); + assert!(!adapter.check_tenant(&activity)); + } + + #[test] + fn tenant_rejected_when_no_tenant_info() { + let adapter = TeamsAdapter::new(make_config(vec!["tenant-a"])); + let activity = make_activity_with_tenant(None); + assert!(!adapter.check_tenant(&activity)); + } + + #[test] + fn tenant_allowed_when_no_tenant_and_empty_list() { + let adapter = TeamsAdapter::new(make_config(vec![])); + let activity = make_activity_with_tenant(None); + assert!(adapter.check_tenant(&activity)); + } + + // --- resolved_tenant_id --- + + #[test] + fn resolved_tenant_falls_back_to_channel_data() { + // Teams personal/channel webhooks put tenant in channelData, not top-level + let json = r#"{ + "type": "message", + "channelData": {"tenant": {"id": "from-channel-data"}} + }"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.resolved_tenant_id(), Some("from-channel-data")); + } + + #[test] + fn resolved_tenant_prefers_top_level_over_channel_data() { + let json = r#"{ + "type": "message", + "tenant": {"id": "top-level"}, + "channelData": {"tenant": {"id": "from-channel-data"}} + }"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.resolved_tenant_id(), Some("top-level")); + } + + #[test] + fn resolved_tenant_falls_back_to_conversation_tenant_id() { + let json = r#"{ + "type": "message", + "conversation": {"id": "c1", "tenantId": "from-conversation"} + }"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.resolved_tenant_id(), Some("from-conversation")); + } + + #[test] + fn resolved_tenant_returns_none_when_absent() { + let json = r#"{"type": "message"}"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.resolved_tenant_id(), None); + } + + // --- validate_jwt error paths --- + + #[tokio::test] + async fn jwt_rejects_missing_bearer_prefix() { + let adapter = TeamsAdapter::new(make_config(vec![])); + let activity = make_activity_with_tenant(Some("t1")); + let result = adapter.validate_jwt("NotBearer xyz", &activity).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Bearer")); + } + + #[tokio::test] + async fn jwt_rejects_empty_bearer() { + let adapter = TeamsAdapter::new(make_config(vec![])); + let activity = make_activity_with_tenant(Some("t1")); + let result = adapter.validate_jwt("Bearer ", &activity).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn jwt_rejects_garbage_token() { + let adapter = TeamsAdapter::new(make_config(vec![])); + let activity = make_activity_with_tenant(Some("t1")); + let result = adapter.validate_jwt("Bearer not.a.valid.jwt", &activity).await; + assert!(result.is_err()); + } + + // --- Activity deserialization --- + + #[test] + fn deserialize_minimal_activity() { + let json = r#"{"type": "message"}"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.activity_type, "message"); + assert!(activity.text.is_none()); + assert!(activity.from.is_none()); + } + + #[test] + fn deserialize_full_activity() { + let json = r#"{ + "type": "message", + "id": "act123", + "serviceUrl": "https://smba.trafficmanager.net/", + "channelId": "msteams", + "from": {"id": "user1", "name": "Alice", "aadObjectId": "aad-123"}, + "conversation": {"id": "conv1", "conversationType": "personal", "isGroup": false}, + "text": "hello bot", + "tenant": {"id": "tenant-abc"} + }"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.activity_type, "message"); + assert_eq!(activity.text.as_deref(), Some("hello bot")); + assert_eq!( + activity.from.as_ref().unwrap().name.as_deref(), + Some("Alice") + ); + assert_eq!( + activity.tenant.as_ref().unwrap().id.as_deref(), + Some("tenant-abc") + ); + } + + #[test] + fn deserialize_non_message_activity() { + let json = r#"{"type": "conversationUpdate"}"#; + let activity: Activity = serde_json::from_str(json).unwrap(); + assert_eq!(activity.activity_type, "conversationUpdate"); + } + + #[test] + fn deserialize_invalid_json_fails() { + let result = serde_json::from_str::("not json"); + assert!(result.is_err()); + } + + // --- TeamsConfig::from_env --- + + #[test] + fn config_from_env_returns_none_without_vars() { + // Ensure the env vars are not set (they shouldn't be in test) + std::env::remove_var("TEAMS_APP_ID"); + std::env::remove_var("TEAMS_APP_SECRET"); + assert!(TeamsConfig::from_env().is_none()); + } +} diff --git a/gateway/src/main.rs b/gateway/src/main.rs index b9485663..1831dad3 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -42,6 +42,10 @@ pub struct AppState { pub line_channel_secret: Option, /// LINE channel access token for reply API pub line_access_token: Option, + /// Teams adapter (None if Teams disabled) + pub teams: Option, + /// service_url cache for Teams reply routing (conversation_id → (service_url, last_seen)) + pub teams_service_urls: Mutex>, /// WebSocket authentication token pub ws_token: Option, /// Broadcast channel: gateway → OAB (events from all platforms) @@ -137,6 +141,18 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: warn!("reply for line but adapter not configured"); } } + "teams" => { + if let Some(ref teams) = state_for_recv.teams { + adapters::teams::handle_reply( + &reply, + teams, + &state_for_recv.teams_service_urls, + ) + .await; + } else { + warn!("reply for teams but adapter not configured"); + } + } other => warn!(platform = other, "unknown reply platform"), } } @@ -199,8 +215,20 @@ async fn main() -> Result<()> { info!("line adapter enabled"); app = app.route("/webhook/line", post(adapters::line::webhook)); - if telegram_bot_token.is_none() && line_access_token.is_none() { - warn!("no adapters configured — set TELEGRAM_BOT_TOKEN and/or LINE_CHANNEL_ACCESS_TOKEN"); + // Teams adapter + let teams = adapters::teams::TeamsConfig::from_env().map(|config| { + info!("teams adapter enabled"); + adapters::teams::TeamsAdapter::new(config) + }); + if teams.is_some() { + let webhook_path = + std::env::var("TEAMS_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/teams".into()); + info!(path = %webhook_path, "teams webhook registered"); + app = app.route(&webhook_path, post(adapters::teams::webhook)); + } + + if telegram_bot_token.is_none() && line_access_token.is_none() && teams.is_none() { + warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, and/or TEAMS_APP_ID + TEAMS_APP_SECRET"); } let state = Arc::new(AppState { @@ -208,6 +236,8 @@ async fn main() -> Result<()> { telegram_secret_token, line_channel_secret, line_access_token, + teams, + teams_service_urls: Mutex::new(HashMap::new()), ws_token, event_tx, reply_token_cache, @@ -237,6 +267,27 @@ async fn main() -> Result<()> { }); } + // Periodic cleanup of stale Teams service_url entries (TTL: 4 hours) + { + let state_for_cleanup = state.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(300)).await; + let mut urls = state_for_cleanup.teams_service_urls.lock().await; + let before = urls.len(); + urls.retain(|_, (_, t)| t.elapsed().as_secs() < 4 * 3600); + let after = urls.len(); + if before != after { + info!( + removed = before - after, + remaining = after, + "teams service_url cache cleanup" + ); + } + } + }); + } + let app = app.with_state(state); info!(addr = %listen_addr, "gateway starting");