From 96aac0a055252951fee699e4a190f6c9d885cb41 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Mon, 6 Jul 2026 13:55:16 +0200 Subject: [PATCH 01/34] feat(teams): Microsoft Teams connector (card-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Microsoft Teams platform connector as a Bot Framework bot: - Inbound webhook with Bot Framework JWT + serviceURL-claim validation and an allow_from AAD-object-id allowlist. - Outbound Bot Connector REST client with AAD client-credentials auth. - Streaming Adaptive Card replies (working card -> answer, native "AI generated" label), rendering uniformly in channels and 1:1. - Engagement: @mention engages a reply thread, then follows it without re-mentioning; messages @mentioning others-but-not-the-bot are ignored; the bot never acts on its own activity. Engagement persists across restarts. - session_scope (thread default | channel | user) selects session grouping; channel scope keys on the channel root. - Teams-Markdown FormattingInstructions; text-based permission prompts. Contained entirely in platform/teams/ plus plugin registration, Makefile, config.example, docs, and the JWT/OAuth2 deps — no core/ changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + Makefile | 2 +- README.md | 4 +- cmd/cc-connect/plugin_platform_teams.go | 5 + config.example.toml | 25 ++ docs/teams.md | 104 ++++++ go.mod | 5 + go.sum | 10 + platform/teams/activity.go | 104 ++++++ platform/teams/activity_test.go | 96 ++++++ platform/teams/auth_inbound.go | 103 ++++++ platform/teams/auth_inbound_test.go | 138 ++++++++ platform/teams/auth_outbound.go | 55 ++++ platform/teams/card.go | 66 ++++ platform/teams/card_test.go | 72 +++++ platform/teams/config.go | 123 +++++++ platform/teams/config_test.go | 129 ++++++++ platform/teams/connector.go | 157 +++++++++ platform/teams/connector_test.go | 193 +++++++++++ platform/teams/format.go | 15 + platform/teams/format_test.go | 21 ++ platform/teams/reply_test.go | 114 +++++++ platform/teams/session.go | 190 +++++++++++ platform/teams/session_test.go | 410 ++++++++++++++++++++++++ platform/teams/streaming_card.go | 114 +++++++ platform/teams/streaming_card_test.go | 155 +++++++++ platform/teams/teams.go | 143 +++++++++ platform/teams/webhook.go | 139 ++++++++ platform/teams/webhook_test.go | 111 +++++++ 29 files changed, 2802 insertions(+), 2 deletions(-) create mode 100644 cmd/cc-connect/plugin_platform_teams.go create mode 100644 docs/teams.md create mode 100644 platform/teams/activity.go create mode 100644 platform/teams/activity_test.go create mode 100644 platform/teams/auth_inbound.go create mode 100644 platform/teams/auth_inbound_test.go create mode 100644 platform/teams/auth_outbound.go create mode 100644 platform/teams/card.go create mode 100644 platform/teams/card_test.go create mode 100644 platform/teams/config.go create mode 100644 platform/teams/config_test.go create mode 100644 platform/teams/connector.go create mode 100644 platform/teams/connector_test.go create mode 100644 platform/teams/format.go create mode 100644 platform/teams/format_test.go create mode 100644 platform/teams/reply_test.go create mode 100644 platform/teams/session.go create mode 100644 platform/teams/session_test.go create mode 100644 platform/teams/streaming_card.go create mode 100644 platform/teams/streaming_card_test.go create mode 100644 platform/teams/teams.go create mode 100644 platform/teams/webhook.go create mode 100644 platform/teams/webhook_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 078d84860..b4deb8ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - **`agent_session_idle_timeout_mins`**: new per-project config option that closes an idle live agent process after a clean turn while preserving the cc-connect session and saved agent session ID. The next message starts a new agent process and resumes the same conversation. Set to `0` or leave unset to disable (#1338). - **Reasonix agent**: new agent adapter for Reasonix multi-model coding agent, bridging via HTTP serve API (POST /submit, SSE /events, POST /approve). Supports default/yolo/plan permission modes, SSE auto-reconnect with backoff, and thinking accumulator. (#1281) - **cloud_web platform**: 新增 self-hosted IM Gateway 作为 first-class platform 接入 (CWIP v1 协议,支持 websocket / long_poll / gateway 3 种 transport,完整 inbound/outbound + capability negotiation + graceful degradation)。 详见 docs/cloud-web.md + #1282。 +- **Microsoft Teams platform**: new Bot Framework connector — inbound webhook with JWT + serviceURL validation, outbound Bot Connector REST with AAD auth, streaming Adaptive Card replies (working card → answer, native AI label), mention-to-engage with per-thread follow, and `session_scope` (thread/channel/user). See docs/teams.md. ## Unreleased diff --git a/Makefile b/Makefile index 59bb36e47..06e37c9e9 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ PLATFORMS := \ # --------------------------------------------------------------------------- ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder reasonix tmux -ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex cloud_web +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max matrix webex teams cloud_web ALL_EXTRAS := web COMMA := , diff --git a/README.md b/README.md index 0c246f3ad..acecc862e 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ High-level view of what each **built-in platform** can do in cc-connect. **10+ AI Agents** — Claude Code, Codex, Cursor Agent, Kimi CLI, Qoder CLI, Gemini CLI, OpenCode, iFlow CLI, Pi, Devin, Copilot — plus any agent that supports the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/get-started/agents). Use whichever fits your workflow, or all of them at once. ### 📱 Platform Flexibility -**13 Chat Platforms** — Feishu, WPS Xiezuo, DingTalk, Slack, Telegram, Discord, WeChat Work, Weibo, LINE, QQ, QQ Bot (Official), Matrix, plus **Weixin (personal ilink)** for **personal WeChat**. Most platforms need **zero public IP**. +**14 Chat Platforms** — Feishu, WPS Xiezuo, DingTalk, Slack, Telegram, Discord, WeChat Work, Weibo, LINE, Microsoft Teams, QQ, QQ Bot (Official), Matrix, plus **Weixin (personal ilink)** for **personal WeChat**. Most platforms need **zero public IP**. ### 🔄 Multi-Agent Orchestration **Multi-Bot Relay** — Bind multiple bots in a group chat and let them communicate with each other. Ask Claude, get insights from Gemini — all in one conversation. @@ -373,6 +373,7 @@ cc-connect update --pre # Include pre-releases | Platform | Discord | ✅ Gateway — no public IP needed | | Platform | Weibo | ✅ WebSocket — no public IP needed | | Platform | LINE | ✅ Webhook — public URL required | +| Platform | Microsoft Teams | ✅ Webhook (Bot Framework) — public URL required | | Platform | WeChat Work | ✅ WebSocket / Webhook | | Platform | Weixin (personal, ilink) | ✅— HTTP long polling — no public IP needed | | Platform | QQ (NapCat/OneBot) | ✅ WebSocket | @@ -395,6 +396,7 @@ cc-connect update --pre # Include pre-releases | Weixin (personal) | [docs/weixin.md](docs/weixin.md) | HTTP long polling (ilink) | No | | QQ / QQ Bot | [docs/qq.md](docs/qq.md) | WebSocket | No | | Matrix | [docs/matrix.md](docs/matrix.md) | /sync (Long Polling) | No | +| Microsoft Teams | [docs/teams.md](docs/teams.md) | Webhook (Bot Framework) | Yes | ## 🎯 Key Features diff --git a/cmd/cc-connect/plugin_platform_teams.go b/cmd/cc-connect/plugin_platform_teams.go new file mode 100644 index 000000000..4a11b3e64 --- /dev/null +++ b/cmd/cc-connect/plugin_platform_teams.go @@ -0,0 +1,5 @@ +//go:build !no_teams + +package main + +import _ "github.com/chenhg5/cc-connect/platform/teams" diff --git a/config.example.toml b/config.example.toml index b7706d1e0..23478df41 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1179,6 +1179,31 @@ app_secret = "your-feishu-app-secret" # token = "YOUR_WEBEX_BOT_ACCESS_TOKEN" # from developer.webex.com (Bot) # allow_from = "you@cisco.com" # comma-separated email allowlist / 逗号分隔的邮箱白名单 +# Microsoft Teams (uncomment to enable / 取消注释以启用) +# 1. Create an Azure Bot resource; note its App ID + client secret +# 创建 Azure Bot 资源,记录 App ID 与客户端密钥 +# 2. Set the bot's "messaging endpoint" to https:// +# 将机器人的"消息终结点"设置为 https://<你的域名> +# Connection: inbound webhook (Bot Framework) — a public HTTPS URL IS required / 需要公网 HTTPS 地址 +# Replies stream as an Adaptive Card. See docs/teams.md for the full setup. / 完整配置见 docs/teams.md。 +# Single-tenant only (tenant_id required) — multi-tenant bot creation is deprecated by Azure. +# 仅支持单租户(必须填 tenant_id)——Azure 已弃用多租户机器人创建。 +# allow_from = "*" (default) permits anyone in your tenant; set an AAD-object-ID allowlist +# to restrict to specific users. / allow_from = "*"(默认)允许本租户任何人;填 AAD 用户 ID 可限定到特定用户。 + +# [[projects.platforms]] +# type = "teams" +# +# [projects.platforms.options] +# app_id = "00000000-0000-0000-0000-000000000000" # Azure Bot App (client) ID +# app_password = "YOUR_CLIENT_SECRET" # Azure Bot client secret +# tenant_id = "00000000-0000-0000-0000-000000000000" # REQUIRED — AAD tenant (directory) ID that owns the bot / 必填:拥有该机器人的 AAD 租户 ID +# webhook_port = "3978" # local bind port (default 3978) +# webhook_path = "/api/messages" # must match the Azure messaging endpoint path +# allow_from = "*" # "*"/empty = all users in the tenant; or comma-separated AAD object IDs / "*"/留空=租户内所有人,或逗号分隔的 AAD 用户 ID +# session_scope = "thread" # "thread" (default) | "channel" | "user" +# card_update_interval_ms = 1500 # streaming card edit throttle (ms; Teams limits edits ~1/s) + # MAX messenger (uncomment to enable / 取消注释以启用) # 1. Create a bot via @MasterBot in MAX, get the access token # 在 MAX 中通过 @MasterBot 创建机器人,获取 access token diff --git a/docs/teams.md b/docs/teams.md new file mode 100644 index 000000000..51b61f591 --- /dev/null +++ b/docs/teams.md @@ -0,0 +1,104 @@ +# Microsoft Teams + +cc-connect connects to Microsoft Teams as a Bot Framework bot. Unlike the +outbound-connection platforms (Slack socket mode, Feishu websocket), Teams +delivers messages by **POSTing to a public HTTPS webhook**, so this platform +requires a publicly reachable URL. + +## How it works + +- Azure Bot Service relays Teams messages to the connector's webhook + (`/api/messages` by default) as Bot Framework activities. +- The connector validates each request's JWT, then forwards the message to the + cc-connect engine. +- Replies are sent back through the Bot Connector REST API and stream as an + **Adaptive Card**: a "working" card is posted immediately, edited in place as + the answer grows, and finalized with the native "AI generated" label. The card + renders uniformly in channels, group chats, and 1:1 (unlike the native + `streamType` protocol, which is one-on-one only). + +Engagement model: in a **channel or group chat**, an **@mention** of the bot +engages that reply thread; subsequent messages **in that thread** are then +followed without re-mentioning. A message that @mentions other people but not the +bot is treated as human-to-human side chatter and ignored. Engagement is +persisted under the cc-connect data dir (`/teams/-engaged.json`), +so it survives a restart. In a **1:1 (personal) chat** the bot responds to every +message — Teams does not allow @mentioning a bot there. + +## Prerequisites + +- An **Azure Bot resource** (Azure Portal → "Azure Bot"), created as a + **single-tenant** app. Note its **Microsoft App ID**, its **tenant (directory) + ID**, and create a **client secret**. (Multi-tenant bots are not supported — + Azure deprecated their creation after 2025-07-31.) +- The bot's **Microsoft Teams** channel enabled. +- A public HTTPS endpoint that routes to the connector's webhook port. + +## Setup + +1. **Create the Azure Bot** (single-tenant) and record the App ID, tenant ID, and client secret. +2. **Set the messaging endpoint** (Azure Bot → Configuration → + "Messaging endpoint") to: + + ``` + https:// + ``` + + where `` matches your config (default `/api/messages`). +3. **Enable the Teams channel** (Azure Bot → Channels → Microsoft Teams). +4. **Configure cc-connect** — add a Teams platform to your project: + + ```toml + [[projects.platforms]] + type = "teams" + + [projects.platforms.options] + app_id = "00000000-0000-0000-0000-000000000000" # Azure Bot App (client) ID + app_password = "YOUR_CLIENT_SECRET" # Azure Bot client secret + tenant_id = "" # required — AAD tenant that owns the bot (single-tenant) + webhook_port = "3978" # local bind port + webhook_path = "/api/messages" # must match the messaging endpoint path + allow_from = "*" # "*"/empty = all users in the tenant, or AAD object IDs + session_scope = "thread" # "thread" (default) | "channel" | "user" + card_update_interval_ms = 1500 # streaming card edit throttle (ms) + ``` + + > **Access control:** the connector is **single-tenant** (`tenant_id` required), so only + > users in that one organization can reach the bot. `allow_from = "*"` (the default) then + > permits everyone in the tenant; set an AAD-object-ID allowlist to restrict to specific + > people when you want a tighter boundary. + +5. **Expose the webhook.** Put a reverse proxy / ingress with TLS in front of + `webhook_port`, or use a tunnel (e.g. for local testing) so Azure Bot Service + can reach `https://`. +6. **Install the bot in Teams** (sideload an app manifest pointing at your bot, + or add it from your org's catalog) and @mention it to start. + +## Options + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `app_id` | yes | — | Azure Bot App (client) ID; the inbound JWT audience | +| `app_password` | yes | — | Azure Bot client secret (outbound token) | +| `tenant_id` | **yes** | — | AAD tenant that owns the bot. Single-tenant only — Azure deprecated multi-tenant bot creation after 2025-07-31 | +| `webhook_port` | no | `3978` | Local port the webhook binds to | +| `webhook_path` | no | `/api/messages` | Path of the messaging endpoint | +| `allow_from` | no | `""` | Comma-separated AAD object IDs allowed to use the bot; `*` or empty = all users in the tenant | +| `session_scope` | no | `thread` | `thread` (one session per reply thread), `channel` (one shared session across the channel), `user` (one session per user within a thread) | +| `card_update_interval_ms` | no | `1500` | Streaming-card edit throttle in ms; Teams rate-limits edits to ~1/s | + +## Connection type + +Webhook (Bot Framework) — **a public HTTPS URL is required**. This is inherent +to the Bot Framework: Teams messages always route through Azure Bot Service, +which POSTs to your endpoint. + +## Limitations (MVP) + +- Replies stream as an Adaptive Card only. Plain text streaming, a `reply_format` + toggle, and the native `streamType` 1:1 animation are deferred to follow-ups. +- Permission prompts render as plain text with numbered options (reply with a + number or `yes`); interactive Adaptive Card buttons are deferred. +- No inbound images/files/audio. +- No cron/timer → Teams proactive messages (a conversation-reference store is + needed to send without an incoming activity). diff --git a/go.mod b/go.mod index dd6d699eb..5bf5ecfa1 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 + github.com/MicahParks/keyfunc/v3 v3.8.0 github.com/bwmarrin/discordgo v0.29.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/go-telegram/bot v1.20.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.0 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/line/line-bot-sdk-go/v8 v8.19.0 @@ -18,6 +20,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/slack-go/slack v0.16.0 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.36.0 maunium.net/go/mautrix v0.27.0 modernc.org/sqlite v1.49.1 rsc.io/qr v0.2.0 @@ -25,6 +28,7 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect + github.com/MicahParks/jwkset v0.11.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -67,6 +71,7 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.9.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 6ec8b0574..a8542e89f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= +github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -46,6 +50,8 @@ github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -144,6 +150,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -164,6 +172,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/platform/teams/activity.go b/platform/teams/activity.go new file mode 100644 index 000000000..f0d544d81 --- /dev/null +++ b/platform/teams/activity.go @@ -0,0 +1,104 @@ +package teams + +import ( + "encoding/json" + "strings" +) + +// activity is the subset of a Bot Framework Activity the connector consumes. +// See https://learn.microsoft.com/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference. +type activity struct { + Type string `json:"type"` + ID string `json:"id"` + Text string `json:"text"` + ServiceURL string `json:"serviceUrl"` + ReplyToID string `json:"replyToId"` + From channelAccount `json:"from"` + Recipient channelAccount `json:"recipient"` + Conversation conversationAccount `json:"conversation"` + Entities []entity `json:"entities"` + Value json.RawMessage `json:"value"` +} + +type channelAccount struct { + ID string `json:"id"` + AADObjectID string `json:"aadObjectId"` + Name string `json:"name"` +} + +type conversationAccount struct { + ID string `json:"id"` + ConversationType string `json:"conversationType"` + Name string `json:"name"` +} + +// entity is a Bot Framework entity; only mention entities are interpreted. +type entity struct { + Type string `json:"type"` + Text string `json:"text"` + Mentioned channelAccount `json:"mentioned"` +} + +func parseActivity(body []byte) (*activity, error) { + var a activity + if err := json.Unmarshal(body, &a); err != nil { + return nil, err + } + return &a, nil +} + +// cleanText returns the message text with bot @mention markup removed. Teams +// includes the mention's display text inline (e.g. "bot hi"); each +// mention entity carries the exact Text span to strip. +func (a *activity) cleanText() string { + text := a.Text + for _, e := range a.Entities { + if strings.EqualFold(e.Type, "mention") && e.Text != "" { + text = strings.ReplaceAll(text, e.Text, "") + } + } + return strings.TrimSpace(text) +} + +// mentionsBot reports whether the activity @mentions this bot (by the bot's app +// ID, which equals the activity recipient ID). +func (a *activity) mentionsBot(botID string) bool { + for _, e := range a.Entities { + if strings.EqualFold(e.Type, "mention") && + (e.Mentioned.ID == botID || (a.Recipient.ID != "" && e.Mentioned.ID == a.Recipient.ID)) { + return true + } + } + return false +} + +// hasMention reports whether the activity carries any @mention entity (targeting +// the bot or anyone else). Used by the engaged-thread follow filter to ignore +// messages addressed to other participants. +func (a *activity) hasMention() bool { + for _, e := range a.Entities { + if strings.EqualFold(e.Type, "mention") { + return true + } + } + return false +} + +// cardAction returns the action string from a card submit (Action.Submit), or +// "" when the activity is not a card action. Teams delivers submits as a message +// activity carrying `value` and (usually) no text. +func (a *activity) cardAction() string { + if len(a.Value) == 0 { + return "" + } + var v map[string]any + if err := json.Unmarshal(a.Value, &v); err != nil { + return "" + } + for _, key := range []string{"action", "cmd"} { + if s, ok := v[key].(string); ok && s != "" { + return s + } + } + return "" +} diff --git a/platform/teams/activity_test.go b/platform/teams/activity_test.go new file mode 100644 index 000000000..89f8aa587 --- /dev/null +++ b/platform/teams/activity_test.go @@ -0,0 +1,96 @@ +package teams + +import "testing" + +func TestCleanText_StripsMention(t *testing.T) { + a := &activity{ + Text: "mybot hello there", + Entities: []entity{ + {Type: "mention", Text: "mybot", Mentioned: channelAccount{ID: "bot-1"}}, + }, + } + if got := a.cleanText(); got != "hello there" { + t.Fatalf("cleanText = %q, want %q", got, "hello there") + } +} + +func TestMentionsBot(t *testing.T) { + a := &activity{ + Recipient: channelAccount{ID: "bot-1"}, + Entities: []entity{ + {Type: "mention", Mentioned: channelAccount{ID: "bot-1"}}, + }, + } + if !a.mentionsBot("bot-1") { + t.Error("expected mentionsBot true when bot id mentioned") + } + + // Recipient fallback: a mention whose Mentioned.ID matches the activity + // recipient counts even if the passed botID differs. + if !a.mentionsBot("some-other-id") { + t.Error("expected mentionsBot true via recipient fallback") + } + + // No recipient, mention is another user → not a bot mention. + b := &activity{Entities: []entity{{Type: "mention", Mentioned: channelAccount{ID: "someone"}}}} + if b.mentionsBot("bot-1") { + t.Error("expected mentionsBot false when only another user is mentioned") + } +} + +func TestCleanText_MultipleMentionsAndNonMention(t *testing.T) { + a := &activity{ + Text: "bot ping alice please", + Entities: []entity{ + {Type: "mention", Text: "bot", Mentioned: channelAccount{ID: "bot-1"}}, + {Type: "mention", Text: "alice", Mentioned: channelAccount{ID: "alice"}}, + {Type: "clientInfo"}, // non-mention entity must be ignored, not panic + }, + } + if got := a.cleanText(); got != "ping please" { + t.Fatalf("cleanText = %q, want %q", got, "ping please") + } +} + +func TestCardAction_Variants(t *testing.T) { + cases := map[string]string{ + `{"action":"act:/x"}`: "act:/x", + `{"cmd":"pause"}`: "pause", + `{"action":123}`: "", // non-string value ignored + `{"action":{}}`: "", + `not json`: "", + `{}`: "", + } + for raw, want := range cases { + a := &activity{Value: []byte(raw)} + if got := a.cardAction(); got != want { + t.Errorf("cardAction(%s) = %q, want %q", raw, got, want) + } + } +} + +func TestCardAction(t *testing.T) { + a := &activity{Value: []byte(`{"action":"act:/heartbeat pause"}`)} + if got := a.cardAction(); got != "act:/heartbeat pause" { + t.Fatalf("cardAction = %q", got) + } + none := &activity{} + if got := none.cardAction(); got != "" { + t.Fatalf("cardAction = %q, want empty", got) + } +} + +func TestSessionKey_Scopes(t *testing.T) { + a := &activity{Conversation: conversationAccount{ID: "conv-9"}, From: channelAccount{ID: "user-7"}} + cases := map[string]string{ + "user": "teams:conv-9:user-7", + "thread": "teams:conv-9", + "channel": "teams:conv-9", + } + for scope, want := range cases { + p := &Platform{cfg: config{sessionScope: scope}} + if got := p.sessionKey(a); got != want { + t.Errorf("scope %q: sessionKey = %q, want %q", scope, got, want) + } + } +} diff --git a/platform/teams/auth_inbound.go b/platform/teams/auth_inbound.go new file mode 100644 index 000000000..83d0452ca --- /dev/null +++ b/platform/teams/auth_inbound.go @@ -0,0 +1,103 @@ +package teams + +import ( + "context" + "fmt" + "time" + + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" +) + +// Bot Framework inbound-token constants. Mirrors the Microsoft 365 Agents SDK +// (jwt_token_validator.py / agent_auth_configuration.py): the channel signs +// activity tokens as the Bot Framework issuer, validated against the ABS JWKS. +const ( + issuerBotFramework = "https://api.botframework.com" + jwksBotFrameworkURL = "https://login.botframework.com/v1/.well-known/keys" + defaultLeeway = 5 * time.Minute +) + +func aadJWKSURL(tenant string) string { + return fmt.Sprintf("https://login.microsoftonline.com/%s/discovery/v2.0/keys", tenant) +} + +// aadIssuers returns the AAD issuer strings accepted for a tenant, matching the +// SDK's ISSUERS list (sts.windows.net v1 and login.microsoftonline.com v2). +func aadIssuers(tenant string) []string { + return []string{ + fmt.Sprintf("https://sts.windows.net/%s/", tenant), + fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenant), + } +} + +// inboundValidator verifies Bot Framework activity JWTs. It selects the signing +// keys by issuer (Bot Framework vs the configured AAD tenant) and enforces +// RS256, audience == app ID, issuer trust, and expiry with leeway. +type inboundValidator struct { + appID string + tenantID string + leeway time.Duration + + bfKeys jwt.Keyfunc // Bot Framework JWKS + aadKeys jwt.Keyfunc // AAD tenant JWKS (nil when no tenant configured) +} + +// newInboundValidator wires JWKS-backed keyfuncs with rotation caching. The AAD +// keyfunc is only created when a tenant is configured. +func newInboundValidator(cfg config) (*inboundValidator, error) { + bf, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksBotFrameworkURL}) + if err != nil { + return nil, fmt.Errorf("teams: bot framework JWKS: %w", err) + } + v := &inboundValidator{ + appID: cfg.appID, + tenantID: cfg.tenantID, + leeway: defaultLeeway, + bfKeys: bf.Keyfunc, + } + if cfg.tenantID != "" { + aad, err := keyfunc.NewDefaultCtx(context.Background(), []string{aadJWKSURL(cfg.tenantID)}) + if err != nil { + return nil, fmt.Errorf("teams: AAD JWKS: %w", err) + } + v.aadKeys = aad.Keyfunc + } + return v, nil +} + +// keyFor selects the verification key by the token's issuer, rejecting any +// issuer the connector does not trust before a signature check is attempted. +func (v *inboundValidator) keyFor(token *jwt.Token) (any, error) { + iss, err := token.Claims.GetIssuer() + if err != nil { + return nil, fmt.Errorf("teams: token missing issuer: %w", err) + } + if iss == issuerBotFramework { + return v.bfKeys(token) + } + if v.aadKeys != nil { + for _, trusted := range aadIssuers(v.tenantID) { + if iss == trusted { + return v.aadKeys(token) + } + } + } + return nil, fmt.Errorf("teams: untrusted issuer %q", iss) +} + +// validate verifies a raw bearer token and returns its claims, or an error if +// the token fails any check. +func (v *inboundValidator) validate(tokenString string) (jwt.MapClaims, error) { + claims := jwt.MapClaims{} + _, err := jwt.ParseWithClaims(tokenString, claims, v.keyFor, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithLeeway(v.leeway), + jwt.WithAudience(v.appID), + jwt.WithExpirationRequired(), + ) + if err != nil { + return nil, fmt.Errorf("teams: token validation failed: %w", err) + } + return claims, nil +} diff --git a/platform/teams/auth_inbound_test.go b/platform/teams/auth_inbound_test.go new file mode 100644 index 000000000..183fb371f --- /dev/null +++ b/platform/teams/auth_inbound_test.go @@ -0,0 +1,138 @@ +package teams + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// testSigner builds tokens for a single RSA key and exposes a keyfunc that +// returns its public key, standing in for a JWKS endpoint. +type testSigner struct { + key *rsa.PrivateKey +} + +func newTestSigner(t *testing.T) *testSigner { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return &testSigner{key: key} +} + +func (s *testSigner) keyfunc(*jwt.Token) (any, error) { return &s.key.PublicKey, nil } + +func (s *testSigner) sign(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signed, err := tok.SignedString(s.key) + if err != nil { + t.Fatalf("sign: %v", err) + } + return signed +} + +func validatorWith(s *testSigner) *inboundValidator { + return &inboundValidator{ + appID: "app-123", + leeway: defaultLeeway, + bfKeys: s.keyfunc, + aadKeys: s.keyfunc, + } +} + +func baseClaims() jwt.MapClaims { + return jwt.MapClaims{ + "iss": issuerBotFramework, + "aud": "app-123", + "exp": time.Now().Add(time.Hour).Unix(), + } +} + +func TestValidate_AcceptsValidToken(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + if _, err := v.validate(s.sign(t, baseClaims())); err != nil { + t.Fatalf("expected valid token to pass, got %v", err) + } +} + +func TestValidate_RejectsWrongAudience(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + c := baseClaims() + c["aud"] = "someone-else" + if _, err := v.validate(s.sign(t, c)); err == nil { + t.Fatal("expected rejection for wrong audience") + } +} + +func TestValidate_RejectsExpired(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + c := baseClaims() + c["exp"] = time.Now().Add(-2 * defaultLeeway).Unix() + if _, err := v.validate(s.sign(t, c)); err == nil { + t.Fatal("expected rejection for expired token") + } +} + +func TestValidate_RejectsUntrustedIssuer(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + c := baseClaims() + c["iss"] = "https://evil.example.com" + if _, err := v.validate(s.sign(t, c)); err == nil { + t.Fatal("expected rejection for untrusted issuer") + } +} + +func TestValidate_RejectsBadSignature(t *testing.T) { + signer := newTestSigner(t) + other := newTestSigner(t) + // Validator trusts `other`'s key, but the token is signed by `signer`. + v := validatorWith(other) + if _, err := v.validate(signer.sign(t, baseClaims())); err == nil { + t.Fatal("expected rejection for token signed by an untrusted key") + } +} + +func TestValidate_RejectsNonRS256(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + // HS256 token must be refused even if the alg-confusion key happened to match. + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, baseClaims()) + hs, err := tok.SignedString([]byte("symmetric")) + if err != nil { + t.Fatalf("sign hs256: %v", err) + } + if _, err := v.validate(hs); err == nil { + t.Fatal("expected rejection for non-RS256 token") + } +} + +func TestValidate_AcceptsAADIssuerWhenTenantSet(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + v.tenantID = "tenant-xyz" + c := baseClaims() + c["iss"] = "https://login.microsoftonline.com/tenant-xyz/v2.0" + if _, err := v.validate(s.sign(t, c)); err != nil { + t.Fatalf("expected AAD-issued token to pass when tenant matches, got %v", err) + } +} + +func TestValidate_RejectsAADIssuerWrongTenant(t *testing.T) { + s := newTestSigner(t) + v := validatorWith(s) + v.tenantID = "tenant-xyz" + c := baseClaims() + c["iss"] = "https://login.microsoftonline.com/other-tenant/v2.0" + if _, err := v.validate(s.sign(t, c)); err == nil { + t.Fatal("expected rejection for AAD token from a different tenant") + } +} diff --git a/platform/teams/auth_outbound.go b/platform/teams/auth_outbound.go new file mode 100644 index 000000000..c34eb6e8a --- /dev/null +++ b/platform/teams/auth_outbound.go @@ -0,0 +1,55 @@ +package teams + +import ( + "context" + "fmt" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// connectorScope is the client-credentials scope for calling the Bot Connector +// (M365 SDK: AGENTS_SDK_SCOPE + "/.default"). +const connectorScope = "https://api.botframework.com/.default" + +// tokenURL is the single-tenant client-credentials endpoint. tenant is required +// (enforced by parseConfig) — the connector does not support multi-tenant bots, +// whose creation Azure deprecated after 2025-07-31. +func tokenURL(tenant string) string { + return fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenant) +} + +// tokenSource yields cached, auto-refreshed Bot Connector access tokens. +type tokenSource interface { + token(ctx context.Context) (string, error) +} + +type oauthTokenSource struct { + src oauth2.TokenSource +} + +// newTokenSource builds a client-credentials token source for the Bot Connector. +func newTokenSource(cfg config) *oauthTokenSource { + return newTokenSourceWithURL(cfg, tokenURL(cfg.tenantID)) +} + +// newTokenSourceWithURL allows overriding the token endpoint (tests). +func newTokenSourceWithURL(cfg config, url string) *oauthTokenSource { + conf := &clientcredentials.Config{ + ClientID: cfg.appID, + ClientSecret: cfg.appPassword, + TokenURL: url, + Scopes: []string{connectorScope}, + AuthStyle: oauth2.AuthStyleInParams, + } + // clientcredentials.Config.TokenSource caches and refreshes internally. + return &oauthTokenSource{src: conf.TokenSource(context.Background())} +} + +func (o *oauthTokenSource) token(ctx context.Context) (string, error) { + tok, err := o.src.Token() + if err != nil { + return "", fmt.Errorf("teams: acquire connector token: %w", err) + } + return tok.AccessToken, nil +} diff --git a/platform/teams/card.go b/platform/teams/card.go new file mode 100644 index 000000000..de51837d5 --- /dev/null +++ b/platform/teams/card.go @@ -0,0 +1,66 @@ +package teams + +// Adaptive Card rendering for streaming replies. Cards are plain map[string]any +// objects marshaled as the attachment content. Body text is a single wrapping +// TextBlock — Teams renders the Adaptive Card markdown subset. + +const adaptiveCardContentType = "application/vnd.microsoft.card.adaptive" + +// loadingCard is the "working" card shown immediately while the agent thinks. +// The text is rendered italic (markdown) and subtle/small — a quiet, grayed cue. +func loadingCard(text string) map[string]any { + if text == "" { + text = defaultCardWorkingText + } + return adaptiveCard([]map[string]any{ + {"type": "TextBlock", "text": "_" + text + "_", "wrap": true, "isSubtle": true, "size": "Small"}, + }) +} + +// answerCard renders the (possibly partial) answer. The "Generated by AI" +// indicator is not drawn in the body — it's the native Teams AI label, attached +// to the final message as an entity (see aiGeneratedEntity). +func answerCard(markdown string) map[string]any { + return adaptiveCard([]map[string]any{ + {"type": "TextBlock", "text": markdown, "wrap": true}, + }) +} + +// aiGeneratedEntity is the message entity that turns on Teams' native "AI +// generated" label (rendered next to the bot name, with the standard +// "AI-generated content may be incorrect" disclaimer). Attach it to the final +// reply's Entities. Works in personal chats, group chats, and channels. +func aiGeneratedEntity() map[string]any { + return map[string]any{ + "type": "https://schema.org/Message", + "@type": "Message", + "@context": "https://schema.org", + "additionalType": []string{"AIGeneratedContent"}, + } +} + +func adaptiveCard(body []map[string]any) map[string]any { + return map[string]any{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": body, + } +} + +// cardActivity wraps a card object as a message activity with an Adaptive Card +// attachment, carrying the conversation-reference envelope. +func cardActivity(rc replyContext, card map[string]any) outboundActivity { + a := newActivity(rc, "message") + a.Attachments = []attachment{{ContentType: adaptiveCardContentType, Content: card}} + return a +} + +// aiCardActivity is cardActivity plus the native "AI generated" label entity. +// Used for every card edit (loading, streaming, final) so the label is present +// from the first render rather than appearing only at the end. +func aiCardActivity(rc replyContext, card map[string]any) outboundActivity { + a := cardActivity(rc, card) + a.Entities = []map[string]any{aiGeneratedEntity()} + return a +} diff --git a/platform/teams/card_test.go b/platform/teams/card_test.go new file mode 100644 index 000000000..96465a485 --- /dev/null +++ b/platform/teams/card_test.go @@ -0,0 +1,72 @@ +package teams + +import ( + "strings" + "testing" +) + +func firstTextBlock(card map[string]any) map[string]any { + body, _ := card["body"].([]map[string]any) + if len(body) == 0 { + return nil + } + return body[0] +} + +func TestLoadingCard(t *testing.T) { + c := loadingCard("💭 Working…") + if c["type"] != "AdaptiveCard" || c["version"] != "1.5" { + t.Fatalf("not an AdaptiveCard 1.5: %v", c) + } + tb := firstTextBlock(c) + txt, _ := tb["text"].(string) + if tb["type"] != "TextBlock" || !strings.Contains(txt, "💭 Working…") { + t.Errorf("loading text block = %v", tb) + } + // grayed + small + italic (markdown) + if tb["isSubtle"] != true || tb["size"] != "Small" { + t.Errorf("loading text should be subtle+Small, got %v", tb) + } + if !strings.HasPrefix(txt, "_") || !strings.HasSuffix(txt, "_") { + t.Errorf("loading text should be italic markdown, got %q", txt) + } +} + +func TestLoadingCard_EmptyFallsBackToDefault(t *testing.T) { + tb := firstTextBlock(loadingCard("")) + if txt, _ := tb["text"].(string); !strings.Contains(txt, defaultCardWorkingText) { + t.Errorf("empty text -> %v, want it to contain default %q", tb["text"], defaultCardWorkingText) + } +} + +func TestAnswerCard_SingleTextBlock(t *testing.T) { + c := answerCard("hello **world**") + body, _ := c["body"].([]map[string]any) + if len(body) != 1 { + t.Fatalf("answer card should be one text block (no body footer), got %d", len(body)) + } + if body[0]["text"] != "hello **world**" || body[0]["wrap"] != true { + t.Errorf("answer block = %v", body[0]) + } +} + +func TestAIGeneratedEntity(t *testing.T) { + e := aiGeneratedEntity() + if e["type"] != "https://schema.org/Message" || e["@type"] != "Message" { + t.Errorf("entity envelope = %v", e) + } + at, _ := e["additionalType"].([]string) + if len(at) != 1 || at[0] != "AIGeneratedContent" { + t.Errorf("additionalType = %v, want [AIGeneratedContent]", e["additionalType"]) + } +} + +func TestCardActivity_WrapsAttachment(t *testing.T) { + a := cardActivity(replyContext{conversationID: "c1"}, loadingCard("hi")) + if a.Type != "message" || len(a.Attachments) != 1 { + t.Fatalf("card activity = %+v", a) + } + if a.Attachments[0].ContentType != adaptiveCardContentType { + t.Errorf("contentType = %q", a.Attachments[0].ContentType) + } +} diff --git a/platform/teams/config.go b/platform/teams/config.go new file mode 100644 index 000000000..8bb19a0d1 --- /dev/null +++ b/platform/teams/config.go @@ -0,0 +1,123 @@ +package teams + +import ( + "fmt" + "log/slog" + "strings" +) + +// Default webhook bind values follow the Bot Framework convention so the Bot +// Framework Emulator and Azure Bot "messaging endpoint" defaults line up. +const ( + defaultWebhookPort = "3978" + defaultWebhookPath = "/api/messages" + defaultCardWorkingText = "💭 Working…" // "working" card label shown while the agent thinks + defaultCardUpdateIntervalMS = 1500 // card edit throttle; Teams rate-limits edits ~1/s +) + +// config holds the resolved Teams platform settings parsed from the config.toml +// `[[projects.platforms]]` options table. +type config struct { + // appID is the Bot/Azure AD application (client) ID. The inbound JWT `aud` + // claim must equal it; the outbound client-credentials grant uses it. + appID string + // appPassword is the application client secret used for the outbound token. + appPassword string + // tenantID is the AAD tenant that owns the Azure Bot resource. It is required: + // the connector is single-tenant only. Azure deprecated multi-tenant bot + // creation after 2025-07-31, so every new bot is single-tenant; requiring the + // tenant also scopes who can reach the bot to that one organization. + tenantID string + + webhookPort string + webhookPath string + + allowFrom string + sessionScope string // "thread" (default) | "channel" | "user" + + cardUpdateIntervalMS int // card edit throttle (ms); smaller = finer chunks (floor ~1s) + + // dataDir and project are injected by cc-connect (cc_data_dir / cc_project) + // and locate the on-disk engagement store. Empty => engagement stays + // in-memory only (e.g. tests / standalone construction). + dataDir string + project string +} + +// parseConfig extracts and validates the Teams config from the platform opts map. +func parseConfig(opts map[string]any) (config, error) { + c := config{ + appID: strings.TrimSpace(stringOpt(opts, "app_id")), + appPassword: stringOpt(opts, "app_password"), + tenantID: strings.TrimSpace(stringOpt(opts, "tenant_id")), + webhookPort: strings.TrimSpace(stringOpt(opts, "webhook_port")), + webhookPath: strings.TrimSpace(stringOpt(opts, "webhook_path")), + allowFrom: stringOpt(opts, "allow_from"), + sessionScope: normalizeSessionScope(opts["session_scope"]), + dataDir: stringOpt(opts, "cc_data_dir"), + project: stringOpt(opts, "cc_project"), + } + c.cardUpdateIntervalMS = intOpt(opts, "card_update_interval_ms", defaultCardUpdateIntervalMS) + if c.cardUpdateIntervalMS <= 0 { + c.cardUpdateIntervalMS = defaultCardUpdateIntervalMS + } + + if c.appID == "" { + return config{}, fmt.Errorf("teams: app_id is required") + } + if c.appPassword == "" { + return config{}, fmt.Errorf("teams: app_password is required") + } + if c.tenantID == "" { + return config{}, fmt.Errorf("teams: tenant_id is required (the connector is single-tenant; multi-tenant bots are deprecated by Azure)") + } + + if c.webhookPort == "" { + c.webhookPort = defaultWebhookPort + } + if c.webhookPath == "" { + c.webhookPath = defaultWebhookPath + } + if !strings.HasPrefix(c.webhookPath, "/") { + c.webhookPath = "/" + c.webhookPath + } + return c, nil +} + +func stringOpt(opts map[string]any, key string) string { + s, _ := opts[key].(string) + return s +} + +// intOpt reads an integer option, returning def when absent or the wrong type. +// TOML/JSON decoders surface numbers as int64 or float64, so accept both. +func intOpt(opts map[string]any, key string, def int) int { + switch n := opts[key].(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + default: + return def + } +} + +// normalizeSessionScope resolves session_scope to one of "thread" | "channel" | +// "user", defaulting to "thread" (Teams is thread-centric) and warning on unknown +// values. +func normalizeSessionScope(raw any) string { + s, _ := raw.(string) + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "thread": + return "thread" + case "channel": + return "channel" + case "user": + return "user" + default: + slog.Warn("teams: unknown session_scope, falling back to thread", "session_scope", s) + return "thread" + } +} diff --git a/platform/teams/config_test.go b/platform/teams/config_test.go new file mode 100644 index 000000000..11f5de1bf --- /dev/null +++ b/platform/teams/config_test.go @@ -0,0 +1,129 @@ +package teams + +import "testing" + +func validOpts() map[string]any { + return map[string]any{ + "app_id": "app-123", + "app_password": "secret", + "tenant_id": "tenant-abc", + } +} + +func TestParseConfig_Valid(t *testing.T) { + c, err := parseConfig(validOpts()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.appID != "app-123" || c.appPassword != "secret" { + t.Fatalf("app credentials not parsed: %+v", c) + } +} + +func TestParseConfig_Defaults(t *testing.T) { + c, err := parseConfig(validOpts()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.webhookPort != defaultWebhookPort { + t.Errorf("webhookPort = %q, want %q", c.webhookPort, defaultWebhookPort) + } + if c.webhookPath != defaultWebhookPath { + t.Errorf("webhookPath = %q, want %q", c.webhookPath, defaultWebhookPath) + } + if c.sessionScope != "thread" { + t.Errorf("sessionScope = %q, want thread", c.sessionScope) + } +} + +func TestParseConfig_MissingAppID(t *testing.T) { + opts := validOpts() + delete(opts, "app_id") + if _, err := parseConfig(opts); err == nil { + t.Fatal("expected error for missing app_id") + } +} + +func TestParseConfig_MissingAppPassword(t *testing.T) { + opts := validOpts() + delete(opts, "app_password") + if _, err := parseConfig(opts); err == nil { + t.Fatal("expected error for missing app_password") + } +} + +func TestParseConfig_MissingTenantID(t *testing.T) { + opts := validOpts() + delete(opts, "tenant_id") + if _, err := parseConfig(opts); err == nil { + t.Fatal("expected error for missing tenant_id (connector is single-tenant only)") + } +} + +func TestParseConfig_WebhookPathNormalized(t *testing.T) { + opts := validOpts() + opts["webhook_path"] = "teams/hook" + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.webhookPath != "/teams/hook" { + t.Errorf("webhookPath = %q, want /teams/hook", c.webhookPath) + } +} + +func TestParseConfig_SessionScope(t *testing.T) { + cases := map[string]string{ + "": "thread", + "thread": "thread", + "channel": "channel", + "user": "user", + "bogus": "thread", + } + for in, want := range cases { + opts := validOpts() + opts["session_scope"] = in + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("scope %q: unexpected error: %v", in, err) + } + if c.sessionScope != want { + t.Errorf("session_scope %q -> %q, want %q", in, c.sessionScope, want) + } + } +} + +func TestParseConfig_CardUpdateIntervalMS(t *testing.T) { + cases := map[any]int{ + int64(900): 900, + 900: 900, + 0: defaultCardUpdateIntervalMS, + -1: defaultCardUpdateIntervalMS, + "nope": defaultCardUpdateIntervalMS, + } + for in, want := range cases { + opts := validOpts() + opts["card_update_interval_ms"] = in + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("interval %v: unexpected error: %v", in, err) + } + if c.cardUpdateIntervalMS != want { + t.Errorf("card_update_interval_ms %v -> %d, want %d", in, c.cardUpdateIntervalMS, want) + } + } + c, _ := parseConfig(validOpts()) + if c.cardUpdateIntervalMS != defaultCardUpdateIntervalMS { + t.Errorf("absent -> %d, want default %d", c.cardUpdateIntervalMS, defaultCardUpdateIntervalMS) + } +} + +func TestNew_RegistersAsPlatform(t *testing.T) { + p, err := New(validOpts()) + if err != nil { + t.Fatalf("New: %v", err) + } + if p.Name() != "teams" { + t.Errorf("Name() = %q, want teams", p.Name()) + } +} diff --git a/platform/teams/connector.go b/platform/teams/connector.go new file mode 100644 index 000000000..e9e0326e2 --- /dev/null +++ b/platform/teams/connector.go @@ -0,0 +1,157 @@ +package teams + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// connectorTimeout bounds outbound Bot Connector calls so a slow or +// attacker-controlled serviceURL cannot hang a request goroutine indefinitely. +const connectorTimeout = 30 * time.Second + +// outboundActivity is the JSON body POSTed/PUT to the Bot Connector. The +// from/recipient/conversation envelope mirrors what the Bot Framework SDKs send +// on every outbound activity (apply_conversation_reference). +type outboundActivity struct { + Type string `json:"type"` // "message" + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + From *channelAccount `json:"from,omitempty"` + Recipient *channelAccount `json:"recipient,omitempty"` + Conversation *conversationAccount `json:"conversation,omitempty"` + Attachments []attachment `json:"attachments,omitempty"` + Entities []map[string]any `json:"entities,omitempty"` +} + +// attachment carries a card payload; for Adaptive Cards ContentType is +// "application/vnd.microsoft.card.adaptive" and Content is the card object. +type attachment struct { + ContentType string `json:"contentType"` + Content any `json:"content"` +} + +// newMessageActivity builds a message activity with the conversation-reference +// envelope (bot as sender, user as recipient, conversation id) populated from +// the reply context. +func newMessageActivity(rc replyContext, text string) outboundActivity { + a := newActivity(rc, "message") + a.Text = text + return a +} + +// newActivity builds an activity of the given type with the conversation-reference +// envelope (bot as sender, user as recipient, conversation id) from the reply +// context. +func newActivity(rc replyContext, typ string) outboundActivity { + a := outboundActivity{Type: typ} + if rc.botAccount.ID != "" { + bot := rc.botAccount + a.From = &bot + } + if rc.userAccount.ID != "" { + user := rc.userAccount + a.Recipient = &user + } + if rc.conversationID != "" { + a.Conversation = &conversationAccount{ID: rc.conversationID} + } + return a +} + +// connector posts activities to the Bot Connector REST API at the per-activity +// serviceUrl, authenticated with a client-credentials bearer token. +type connector struct { + tokens tokenSource + http *http.Client +} + +func newConnector(tokens tokenSource) *connector { + return &connector{tokens: tokens, http: &http.Client{Timeout: connectorTimeout}} +} + +// resourceResponse is the Bot Connector reply to a send; ID identifies the +// created activity (used to address in-place edits for streaming preview). +type resourceResponse struct { + ID string `json:"id"` +} + +// send POSTs a new activity to {serviceURL}/v3/conversations/{conversationID}/activities +// and returns the created activity id. +func (c *connector) send(ctx context.Context, rc replyContext, a outboundActivity) (string, error) { + if rc.serviceURL == "" || rc.conversationID == "" { + return "", fmt.Errorf("teams: reply context missing serviceURL/conversationID") + } + url := fmt.Sprintf("%s/v3/conversations/%s/activities", + strings.TrimRight(rc.serviceURL, "/"), rc.conversationID) + return c.sendTo(ctx, url, a) +} + +// replyTo POSTs a threaded reply to .../activities/{activityID}, the Bot +// Framework reply-to-activity endpoint (threading is keyed by the URL, not a +// body field). +func (c *connector) replyTo(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error { + if rc.serviceURL == "" || rc.conversationID == "" || activityID == "" { + return fmt.Errorf("teams: replyTo missing serviceURL/conversationID/activityID") + } + url := fmt.Sprintf("%s/v3/conversations/%s/activities/%s", + strings.TrimRight(rc.serviceURL, "/"), rc.conversationID, activityID) + _, err := c.sendTo(ctx, url, a) + return err +} + +// update PUTs an edited activity to .../activities/{activityID}, editing the +// message in place (used for streaming preview). +func (c *connector) update(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error { + if rc.serviceURL == "" || rc.conversationID == "" || activityID == "" { + return fmt.Errorf("teams: update missing serviceURL/conversationID/activityID") + } + a.ID = activityID + url := fmt.Sprintf("%s/v3/conversations/%s/activities/%s", + strings.TrimRight(rc.serviceURL, "/"), rc.conversationID, activityID) + _, err := c.do(ctx, http.MethodPut, url, a) + return err +} + +func (c *connector) sendTo(ctx context.Context, url string, a outboundActivity) (string, error) { + body, err := c.do(ctx, http.MethodPost, url, a) + if err != nil { + return "", err + } + var rr resourceResponse + _ = json.Unmarshal(body, &rr) // id is best-effort + return rr.ID, nil +} + +func (c *connector) do(ctx context.Context, method, url string, a outboundActivity) ([]byte, error) { + payload, err := json.Marshal(a) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + token, err := c.tokens.token(ctx) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("teams: connector %s: %w", method, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("teams: connector returned %d", resp.StatusCode) + } + return body, nil +} diff --git a/platform/teams/connector_test.go b/platform/teams/connector_test.go new file mode 100644 index 000000000..6178f0963 --- /dev/null +++ b/platform/teams/connector_test.go @@ -0,0 +1,193 @@ +package teams + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// staticTokens is a tokenSource returning a fixed token and counting calls. +type staticTokens struct { + value string + calls int32 +} + +func (s *staticTokens) token(context.Context) (string, error) { + atomic.AddInt32(&s.calls, 1) + return s.value, nil +} + +func TestConnectorSend_PostsToCorrectURLWithBearer(t *testing.T) { + var gotPath, gotAuth string + var gotBody outboundActivity + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"posted-1"}`)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "tok-abc"}) + rc := replyContext{serviceURL: srv.URL, conversationID: "conv-9"} + + id, err := c.send(context.Background(), rc, outboundActivity{Type: "message", Text: "hello"}) + if err != nil { + t.Fatalf("send: %v", err) + } + if id != "posted-1" { + t.Errorf("id = %q, want posted-1", id) + } + if gotPath != "/v3/conversations/conv-9/activities" { + t.Errorf("path = %q", gotPath) + } + if gotAuth != "Bearer tok-abc" { + t.Errorf("auth = %q", gotAuth) + } + if gotBody.Type != "message" || gotBody.Text != "hello" { + t.Errorf("body = %+v", gotBody) + } +} + +type failTokens struct{} + +func (failTokens) token(context.Context) (string, error) { + return "", context.DeadlineExceeded +} + +func TestConnectorSend_TokenErrorAbortsBeforeHTTP(t *testing.T) { + var hit bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := newConnector(failTokens{}) + _, err := c.send(context.Background(), replyContext{serviceURL: srv.URL, conversationID: "c"}, outboundActivity{Type: "message"}) + if err == nil { + t.Fatal("expected token error to abort send") + } + if hit { + t.Error("no HTTP request should be made when the token cannot be acquired") + } +} + +func TestConnectorUpdate_PutsToActivityURL(t *testing.T) { + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + rc := replyContext{serviceURL: srv.URL, conversationID: "c1"} + if err := c.update(context.Background(), rc, "act-9", outboundActivity{Type: "message", Text: "x"}); err != nil { + t.Fatalf("update: %v", err) + } + if gotMethod != http.MethodPut { + t.Errorf("method = %s, want PUT", gotMethod) + } + if gotPath != "/v3/conversations/c1/activities/act-9" { + t.Errorf("path = %q", gotPath) + } +} + +func TestConnectorUpdate_RejectsEmptyActivityID(t *testing.T) { + c := newConnector(&staticTokens{value: "t"}) + rc := replyContext{serviceURL: "https://s/", conversationID: "c1"} + if err := c.update(context.Background(), rc, "", outboundActivity{Type: "message"}); err == nil { + t.Fatal("expected error for empty activityID") + } +} + +func TestConnectorSend_ErrorsOnNon2xx(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`denied`)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + _, err := c.send(context.Background(), replyContext{serviceURL: srv.URL, conversationID: "c"}, outboundActivity{Type: "message"}) + if err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("expected 403 error, got %v", err) + } +} + +func TestConnectorSend_MissingContextErrors(t *testing.T) { + c := newConnector(&staticTokens{value: "t"}) + if _, err := c.send(context.Background(), replyContext{}, outboundActivity{Type: "message"}); err == nil { + t.Fatal("expected error for missing serviceURL/conversationID") + } +} + +func TestConnectorSend_SerializesAdaptiveCardAttachment(t *testing.T) { + var raw map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &raw) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"x"}`)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + rc := replyContext{serviceURL: srv.URL, conversationID: "c"} + a := newActivity(rc, "message") + a.Attachments = []attachment{{ContentType: "application/vnd.microsoft.card.adaptive", Content: map[string]any{"type": "AdaptiveCard"}}} + + if _, err := c.send(context.Background(), rc, a); err != nil { + t.Fatalf("send: %v", err) + } + atts, ok := raw["attachments"].([]any) + if !ok || len(atts) != 1 { + t.Fatalf("attachments not serialized: %v", raw["attachments"]) + } + att := atts[0].(map[string]any) + if att["contentType"] != "application/vnd.microsoft.card.adaptive" { + t.Errorf("contentType = %v", att["contentType"]) + } + if content, _ := att["content"].(map[string]any); content["type"] != "AdaptiveCard" { + t.Errorf("content not round-tripped: %v", att["content"]) + } +} + +func TestNewMessageActivity_OmitsAttachmentsWhenNone(t *testing.T) { + b, _ := json.Marshal(newMessageActivity(replyContext{conversationID: "c"}, "hi")) + if strings.Contains(string(b), "attachments") { + t.Errorf("text activity should omit attachments: %s", b) + } +} + +func TestTokenSource_CachesAndReuses(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"abc","token_type":"Bearer","expires_in":3600}`)) + })) + defer srv.Close() + + ts := newTokenSourceWithURL(config{appID: "id", appPassword: "secret"}, srv.URL) + for i := 0; i < 3; i++ { + tok, err := ts.token(context.Background()) + if err != nil { + t.Fatalf("token: %v", err) + } + if tok != "abc" { + t.Fatalf("token = %q, want abc", tok) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("token endpoint hit %d times, want 1 (cached)", got) + } +} diff --git a/platform/teams/format.go b/platform/teams/format.go new file mode 100644 index 000000000..a25fc6110 --- /dev/null +++ b/platform/teams/format.go @@ -0,0 +1,15 @@ +package teams + +// FormattingInstructions tells the agent how to format replies for Teams. Teams +// renders a subset of standard Markdown in bot messages. +func (p *Platform) FormattingInstructions() string { + return `You are responding in Microsoft Teams. Use standard Markdown: +- Bold: **text** +- Italic: _text_ +- Inline code: ` + "`text`" + ` and fenced code blocks with ` + "```" + ` +- Lists: ordered and unordered lists render normally +- Links: [display text](url) +- Blockquote: > text +- Headings (#, ##) render but keep them small; prefer **bold** lines for emphasis. +- Do NOT rely on Markdown tables or Markdown image syntax — Teams does not render them reliably in bot messages.` +} diff --git a/platform/teams/format_test.go b/platform/teams/format_test.go new file mode 100644 index 000000000..294e3146f --- /dev/null +++ b/platform/teams/format_test.go @@ -0,0 +1,21 @@ +package teams + +import ( + "strings" + "testing" +) + +func TestFormattingInstructions_NonEmptyTeamsGuidance(t *testing.T) { + p := &Platform{} + got := p.FormattingInstructions() + if got == "" { + t.Fatal("FormattingInstructions must be non-empty") + } + if !strings.Contains(got, "Teams") { + t.Error("instructions should name the Teams platform") + } + // Teams uses standard Markdown bold (**), unlike Slack's single-asterisk mrkdwn. + if !strings.Contains(got, "**text**") { + t.Error("instructions should specify standard Markdown bold") + } +} diff --git a/platform/teams/reply_test.go b/platform/teams/reply_test.go new file mode 100644 index 000000000..aa799851c --- /dev/null +++ b/platform/teams/reply_test.go @@ -0,0 +1,114 @@ +package teams + +import ( + "context" + "testing" +) + +type fakeSender struct { + last outboundActivity + rc replyContext + id string + err error // injected error for send/replyTo/update + updates []outboundActivity + updatedIDs []string + replied []outboundActivity + repliedToID []string +} + +func (f *fakeSender) send(_ context.Context, rc replyContext, a outboundActivity) (string, error) { + f.last = a + f.rc = rc + return f.id, f.err +} + +func (f *fakeSender) replyTo(_ context.Context, _ replyContext, activityID string, a outboundActivity) error { + f.replied = append(f.replied, a) + f.repliedToID = append(f.repliedToID, activityID) + return f.err +} + +func (f *fakeSender) update(_ context.Context, _ replyContext, activityID string, a outboundActivity) error { + f.updates = append(f.updates, a) + f.updatedIDs = append(f.updatedIDs, activityID) + return f.err +} + +func TestReply_ThreadsViaReplyEndpoint(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{ + serviceURL: "https://s/", conversationID: "c1", activityID: "a1", + botAccount: channelAccount{ID: "bot"}, userAccount: channelAccount{ID: "user"}, + } + + if err := p.Reply(context.Background(), rc, "hi there"); err != nil { + t.Fatalf("Reply: %v", err) + } + if len(fs.replied) != 1 || fs.repliedToID[0] != "a1" { + t.Fatalf("Reply should use the reply endpoint for activity a1, got %+v / %v", fs.replied, fs.repliedToID) + } + got := fs.replied[0] + if got.Type != "message" || got.Text != "hi there" { + t.Errorf("activity = %+v", got) + } + // Conversation-reference envelope present: from=bot, recipient=user. + if got.From == nil || got.From.ID != "bot" || got.Recipient == nil || got.Recipient.ID != "user" { + t.Errorf("missing/incorrect envelope: from=%+v recipient=%+v", got.From, got.Recipient) + } + if got.Conversation == nil || got.Conversation.ID != "c1" { + t.Errorf("missing conversation envelope: %+v", got.Conversation) + } +} + +func TestReply_FallsBackToSendWithoutActivityID(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1"} // no activityID + + if err := p.Reply(context.Background(), rc, "hi"); err != nil { + t.Fatalf("Reply: %v", err) + } + if len(fs.replied) != 0 || fs.last.Text != "hi" { + t.Errorf("expected fallback to send, got replied=%v last=%+v", fs.replied, fs.last) + } +} + +func TestSend_PostsNewMessage(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1"} + + if err := p.Send(context.Background(), rc, "broadcast"); err != nil { + t.Fatalf("Send: %v", err) + } + if fs.last.Type != "message" || fs.last.Text != "broadcast" { + t.Errorf("activity = %+v", fs.last) + } + if len(fs.replied) != 0 { + t.Errorf("Send must not use the reply endpoint") + } +} + +func TestReply_RejectsBadReplyCtx(t *testing.T) { + p := &Platform{conn: &fakeSender{}} + if err := p.Reply(context.Background(), "not-a-ctx", "x"); err == nil { + t.Fatal("expected error for invalid reply context") + } +} + +func TestReconstructReplyCtx(t *testing.T) { + p := &Platform{} + got, err := p.ReconstructReplyCtx("teams:conv-42") + if err != nil { + t.Fatalf("ReconstructReplyCtx: %v", err) + } + rc, ok := got.(replyContext) + if !ok || rc.conversationID != "conv-42" { + t.Fatalf("reconstructed = %+v", got) + } + + if _, err := p.ReconstructReplyCtx("slack:foo"); err == nil { + t.Error("expected error for non-teams session key") + } +} diff --git a/platform/teams/session.go b/platform/teams/session.go new file mode 100644 index 000000000..0ad972a67 --- /dev/null +++ b/platform/teams/session.go @@ -0,0 +1,190 @@ +package teams + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/chenhg5/cc-connect/core" +) + +// replyContext carries everything the outbound side needs to answer an activity: +// the serviceURL + conversation to address, the inbound activity id (for threaded +// replies), and the conversation-reference accounts (bot as sender, user as +// recipient) the Bot Connector envelope expects. +type replyContext struct { + serviceURL string + conversationID string + // sessionKey is the engine turn key, derived per session_scope (see sessionKey). + sessionKey string + activityID string // the inbound activity id, used as replyToId + botAccount channelAccount // the bot (inbound recipient) — outbound `from` + userAccount channelAccount // the user (inbound from) — outbound `recipient` +} + +// engagement tracks which conversations the bot has joined. A bot @mention +// engages a conversation; afterwards messages in it are followed without a +// re-mention. The engaged set is persisted to disk (when a path is configured) +// so engagement survives a process restart; with an empty path it is in-memory +// only (tests / standalone construction). +type engagement struct { + mu sync.Mutex + engaged map[string]bool + path string +} + +// newEngagement creates an engagement set, loading any previously persisted +// state from path. An empty path disables persistence. +func newEngagement(path string) *engagement { + e := &engagement{engaged: make(map[string]bool), path: path} + e.load() + return e +} + +// engagementPath locates the per-project engagement store under the cc-connect +// data dir, mirroring the sessions/ convention. Empty inputs disable +// persistence. +func engagementPath(dataDir, project string) string { + if dataDir == "" || project == "" { + return "" + } + return filepath.Join(dataDir, "teams", sanitizeSegment(project)+"-engaged.json") +} + +// sanitizeSegment neutralizes path separators in a config-supplied value used as +// a filename segment (mirrors the weixin platform's handling). +func sanitizeSegment(s string) string { + return strings.NewReplacer("/", "_", "\\", "_", ":", "_", "\x00", "_").Replace(s) +} + +func (e *engagement) load() { + if e.path == "" { + return + } + data, err := os.ReadFile(e.path) + if err != nil { + if !os.IsNotExist(err) { + slog.Warn("teams: cannot read engagement store", "path", e.path, "error", err) + } + return // missing/unreadable => start empty + } + var keys []string + if err := json.Unmarshal(data, &keys); err != nil { + slog.Warn("teams: ignoring corrupt engagement store", "path", e.path, "error", err) + return + } + for _, k := range keys { + e.engaged[k] = true + } +} + +func (e *engagement) engage(key string) { + e.mu.Lock() + defer e.mu.Unlock() + if e.engaged[key] { + return // already engaged — no rewrite + } + e.engaged[key] = true + e.persistLocked() +} + +func (e *engagement) isEngaged(key string) bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.engaged[key] +} + +// persistLocked writes the engaged set via core.AtomicWriteFile (temp + fsync + +// rename). The caller must hold e.mu, so writes are serialized — this runs on +// the inbound webhook path but only on a genuinely new engagement (engage() +// early-returns for already-engaged keys), so writes are rare. Failures are +// logged, not fatal — a missed persist only costs a re-mention after a restart. +func (e *engagement) persistLocked() { + if e.path == "" { + return + } + keys := make([]string, 0, len(e.engaged)) + for k := range e.engaged { + keys = append(keys, k) + } + data, err := json.Marshal(keys) + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(e.path), 0o755); err != nil { + slog.Warn("teams: cannot create engagement dir", "error", err) + return + } + if err := core.AtomicWriteFile(e.path, data, 0o644); err != nil { + slog.Warn("teams: cannot persist engagement store", "error", err) + } +} + +// sessionKey derives the engine session key for an activity per session_scope: +// - "thread" (default): the full conversation.id, which for a channel message +// already identifies the reply thread (`19:...@thread.tacv2;messageid=`); +// a 1:1 or group id has no ";messageid=" suffix and stands alone. +// - "channel": the channel root — conversation.id with the ";messageid=" thread +// suffix stripped — so every thread in a channel shares one session. +// - "user": the thread id plus the sender, so each user gets an isolated session. +func (p *Platform) sessionKey(a *activity) string { + conv := a.Conversation.ID + switch p.cfg.sessionScope { + case "channel": + root, _, _ := strings.Cut(conv, ";messageid=") + return fmt.Sprintf("teams:%s", root) + case "user": + return fmt.Sprintf("teams:%s:%s", conv, a.From.ID) + default: // "thread" + return fmt.Sprintf("teams:%s", conv) + } +} + +// conversationFromSessionKey extracts the conversation id from a session key of +// the form "teams:" (thread/channel scope). Teams conversation +// ids themselves contain ":", so user-scoped keys ("...:") cannot be +// split unambiguously here; that is acceptable because proactive sends (the only +// caller needing this) are deferred and also require a stored serviceURL. +func conversationFromSessionKey(key string) (string, error) { + rest, ok := strings.CutPrefix(key, "teams:") + if !ok || rest == "" { + return "", fmt.Errorf("teams: not a teams session key: %q", key) + } + return rest, nil +} + +// shouldHandle applies the mention-gate + engaged-thread follow model and reports +// whether the activity should be dispatched. Card actions always pass. Personal +// (1:1) chats always pass: Teams does not allow @mentioning a bot in a personal +// chat, so a mention gate there would silently drop every DM. In a channel/group, +// an @mention engages the thread; afterwards messages in that thread are followed +// without re-mentioning, EXCEPT a message that @mentions other participants but +// not the bot — that is human-to-human side chatter and is ignored (R7). +func (p *Platform) shouldHandle(a *activity, isCardAction bool) bool { + // Belt-and-suspenders: never act on the bot's own activity. Bot Framework does + // not redeliver a bot's outbound messages, but a multi-bot install or platform + // echo must not be able to self-trigger a loop. + if p.cfg.appID != "" && a.From.ID == p.cfg.appID { + return false + } + if isCardAction { + return true + } + if strings.EqualFold(a.Conversation.ConversationType, "personal") { + return true + } + key := a.Conversation.ID + if a.mentionsBot(p.cfg.appID) { + p.engaged.engage(key) + return true + } + // A message that mentions someone, but not the bot, is aimed at another person. + if a.hasMention() { + return false + } + return p.engaged.isEngaged(key) +} diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go new file mode 100644 index 000000000..c2d3d6f8b --- /dev/null +++ b/platform/teams/session_test.go @@ -0,0 +1,410 @@ +package teams + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chenhg5/cc-connect/core" + "github.com/golang-jwt/jwt/v5" +) + +func mustJSON(a activity) []byte { + b, err := json.Marshal(a) + if err != nil { + panic(err) + } + return b +} + +// collector captures dispatched messages for assertions. +func collector() (core.MessageHandler, *[]*core.Message) { + var got []*core.Message + h := func(_ core.Platform, m *core.Message) { got = append(got, m) } + return h, &got +} + +func teamsPlatform(scope string) *Platform { + return &Platform{ + cfg: config{appID: "bot-1", sessionScope: scope}, + engaged: newEngagement(""), + } +} + +func messageActivity(conv, user, text string, mention bool) []byte { + a := activity{ + Type: "message", + ID: "act-1", + Text: text, + ServiceURL: "https://smba.example/", + From: channelAccount{ID: user, Name: "User"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: conv}, + } + if mention { + a.Entities = []entity{{Type: "mention", Text: "bot", Mentioned: channelAccount{ID: "bot-1"}}} + a.Text = "bot " + text + } + b := mustJSON(a) + return b +} + +func TestEngagement_PersistsAcrossRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "teams", "proj-engaged.json") + + // First "process": engage a conversation. + e1 := newEngagement(path) + e1.engage("conv-A") + if !e1.isEngaged("conv-A") { + t.Fatal("conv-A should be engaged in first instance") + } + + // Second "process": a fresh engagement loading the same path resumes state. + e2 := newEngagement(path) + if !e2.isEngaged("conv-A") { + t.Fatal("engagement should survive restart (loaded from disk)") + } + if e2.isEngaged("conv-B") { + t.Fatal("unengaged conversation must not be engaged after load") + } +} + +func TestEngagement_AlreadyEngagedDoesNotRewrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "engaged.json") + e := newEngagement(path) + e.engage("conv-A") + + // Tamper with the file; re-engaging the same key must NOT rewrite it. + if err := os.WriteFile(path, []byte("SENTINEL"), 0o644); err != nil { + t.Fatal(err) + } + e.engage("conv-A") + data, _ := os.ReadFile(path) + if string(data) != "SENTINEL" { + t.Error("re-engaging an already-engaged conversation should not rewrite the store") + } + + // A new key DOES rewrite. + e.engage("conv-B") + data, _ = os.ReadFile(path) + if string(data) == "SENTINEL" { + t.Error("engaging a new conversation should rewrite the store") + } +} + +func TestEngagementPath_SanitizesProject(t *testing.T) { + got := engagementPath("/data", "evil/../proj") + if filepath.Base(filepath.Dir(got)) != "teams" { + t.Fatalf("project segment escaped the teams dir: %q", got) + } + if got != "/data/teams/evil_.._proj-engaged.json" { + t.Errorf("path = %q", got) + } +} + +func TestEngagement_InMemoryWhenNoPath(t *testing.T) { + e := newEngagement("") + e.engage("conv-A") // must not panic / must not write anywhere + if !e.isEngaged("conv-A") { + t.Fatal("in-memory engagement should still work") + } +} + +func TestEngagement_CorruptStoreIgnored(t *testing.T) { + path := filepath.Join(t.TempDir(), "engaged.json") + if err := os.WriteFile(path, []byte("{not valid json"), 0o644); err != nil { + t.Fatal(err) + } + e := newEngagement(path) // must not fail; starts empty + if e.isEngaged("anything") { + t.Fatal("corrupt store should yield an empty engaged set") + } + // And it should recover by persisting fresh state. + e.engage("conv-A") + if !newEngagement(path).isEngaged("conv-A") { + t.Fatal("should overwrite corrupt store with valid state") + } +} + +func TestEngagementPath(t *testing.T) { + if got := engagementPath("", "p"); got != "" { + t.Errorf("empty dataDir should disable persistence, got %q", got) + } + if got := engagementPath("/data", ""); got != "" { + t.Errorf("empty project should disable persistence, got %q", got) + } + if got := engagementPath("/data", "mybot"); got != "/data/teams/mybot-engaged.json" { + t.Errorf("path = %q", got) + } +} + +func TestDispatch_PersonalChatNoMentionForwarded(t *testing.T) { + p := teamsPlatform("user") + h, got := collector() + p.handler = h + + a := activity{ + Type: "message", + ID: "act-1", + Text: "hello bot", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "dm-1", ConversationType: "personal"}, + } + p.dispatch(nil, mustJSON(a)) + if len(*got) != 1 { + t.Fatalf("personal chat message must be forwarded without a mention, got %d", len(*got)) + } +} + +func TestDispatch_AllowListMatchesAADObjectID(t *testing.T) { + p := teamsPlatform("thread") + p.cfg.allowFrom = "aad-stable" + h, got := collector() + p.handler = h + + a := activity{ + Type: "message", + ID: "act-1", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "chan-id", AADObjectID: "aad-stable"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "conv-A", ConversationType: "personal"}, + Text: "hi", + } + p.dispatch(nil, mustJSON(a)) + if len(*got) != 1 { + t.Fatalf("allow_from should match the AAD object id, got %d", len(*got)) + } + if (*got)[0].UserID != "aad-stable" { + t.Errorf("UserID = %q, want aad-stable (AAD preferred)", (*got)[0].UserID) + } + + // The channel id must NOT match when an AAD id is present. + p2 := teamsPlatform("thread") + p2.cfg.allowFrom = "chan-id" + h2, got2 := collector() + p2.handler = h2 + p2.dispatch(nil, mustJSON(a)) + if len(*got2) != 0 { + t.Fatalf("channel id must not satisfy allow_from when AAD id present, got %d", len(*got2)) + } +} + +func TestDispatch_ServiceURLClaimMismatchDropped(t *testing.T) { + p := teamsPlatform("personal") + h, got := collector() + p.handler = h + + claims := jwt.MapClaims{"serviceurl": "https://legit.example/"} + a := activity{ + Type: "message", + ID: "act-1", + ServiceURL: "https://attacker.example/", // body disagrees with token claim + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "conv-A", ConversationType: "personal"}, + Text: "hi", + } + p.dispatch(claims, mustJSON(a)) + if len(*got) != 0 { + t.Fatalf("activity with mismatched serviceUrl claim must be dropped, got %d", len(*got)) + } + + // Matching claim passes. + claims["serviceurl"] = "https://attacker.example/" + p.dispatch(claims, mustJSON(a)) + if len(*got) != 1 { + t.Fatalf("matching serviceUrl claim should pass, got %d", len(*got)) + } +} + +func TestDispatch_CrossUserEngagement(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + + // user-1 engages conv-A via mention. + p.dispatch(nil, messageActivity("conv-A", "user-1", "hey bot", true)) + // user-2 (no mention) in the same engaged conversation is now followed. + p.dispatch(nil, messageActivity("conv-A", "user-2", "and me", false)) + if len(*got) != 2 { + t.Fatalf("engaged conversation should follow other users too, got %d", len(*got)) + } +} + +func TestDispatch_MentionStartsSessionThenAutoFollows(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + + // 1) First message without mention in a fresh conversation -> ignored. + p.dispatch(nil, messageActivity("conv-A", "user-1", "hello", false)) + if len(*got) != 0 { + t.Fatalf("non-engaged non-mention should be ignored, got %d", len(*got)) + } + + // 2) Mention engages the conversation and is forwarded. + p.dispatch(nil, messageActivity("conv-A", "user-1", "are you there", true)) + if len(*got) != 1 { + t.Fatalf("mention should be forwarded, got %d", len(*got)) + } + if (*got)[0].Content != "are you there" { + t.Errorf("content = %q, want mention stripped", (*got)[0].Content) + } + + // 3) Follow-up without mention now auto-follows. + p.dispatch(nil, messageActivity("conv-A", "user-1", "thanks", false)) + if len(*got) != 2 { + t.Fatalf("engaged follow-up should be forwarded, got %d", len(*got)) + } + + // 4) A different conversation is still gated. + p.dispatch(nil, messageActivity("conv-B", "user-2", "hi", false)) + if len(*got) != 2 { + t.Fatalf("other conversation must stay gated, got %d", len(*got)) + } +} + +func TestDispatch_CardActionAlwaysForwarded(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + + a := activity{ + Type: "message", + ID: "act-2", + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "conv-C"}, + Value: []byte(`{"action":"allow"}`), + } + b := mustJSON(a) + p.dispatch(nil, b) + + if len(*got) != 1 { + t.Fatalf("card action should be forwarded even when not engaged, got %d", len(*got)) + } + if (*got)[0].Content != "allow" || !(*got)[0].IsPermissionResponse { + t.Errorf("card action message = %+v, want content=allow IsPermissionResponse=true", (*got)[0]) + } +} + +func TestDispatch_NonMessageIgnored(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + p.dispatch(nil, []byte(`{"type":"typing"}`)) + if len(*got) != 0 { + t.Fatalf("non-message activity should be ignored, got %d", len(*got)) + } +} + +func TestDispatch_AllowListBlocks(t *testing.T) { + p := teamsPlatform("thread") + p.cfg.allowFrom = "approved-user" + h, got := collector() + p.handler = h + // engaged via mention but user not on allow list -> blocked + p.dispatch(nil, messageActivity("conv-D", "random-user", "hi", true)) + if len(*got) != 0 { + t.Fatalf("allow-list should block unauthorized user, got %d", len(*got)) + } +} + +// TestSessionKey_ScopeVariants covers AE8: the three scopes produce distinct +// keys, and channel scope collapses sibling threads to the channel root. +func TestSessionKey_ScopeVariants(t *testing.T) { + const thread = "19:abc@thread.tacv2;messageid=1700000000000" + const root = "19:abc@thread.tacv2" + a := &activity{From: channelAccount{ID: "user-1"}, Conversation: conversationAccount{ID: thread}} + + if got := teamsPlatform("thread").sessionKey(a); got != "teams:"+thread { + t.Errorf("thread scope = %q, want teams:%s", got, thread) + } + if got := teamsPlatform("channel").sessionKey(a); got != "teams:"+root { + t.Errorf("channel scope = %q, want teams:%s (messageid stripped)", got, root) + } + if got := teamsPlatform("user").sessionKey(a); got != "teams:"+thread+":user-1" { + t.Errorf("user scope = %q, want per-user key", got) + } + + // Two reply threads of one channel collapse to the same channel-scope key. + sibling := &activity{From: channelAccount{ID: "user-1"}, Conversation: conversationAccount{ID: "19:abc@thread.tacv2;messageid=999"}} + if teamsPlatform("channel").sessionKey(a) != teamsPlatform("channel").sessionKey(sibling) { + t.Error("channel scope should collapse sibling threads to one key") + } + // A 1:1 id has no ;messageid= suffix, so channel scope leaves it unchanged. + dm := &activity{From: channelAccount{ID: "u"}, Conversation: conversationAccount{ID: "a:1to1"}} + if got := teamsPlatform("channel").sessionKey(dm); got != "teams:a:1to1" { + t.Errorf("channel scope on a suffixless id = %q, want unchanged", got) + } +} + +// messageMentioning builds a channel message that @mentions the given ids (by +// Mentioned.ID) without mentioning the bot unless bot-1 is in the list. +func messageMentioning(conv, from string, mentionedIDs ...string) []byte { + a := activity{ + Type: "message", + ID: "act-m", + Text: "mentioning others", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: from}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: conv}, + } + for _, id := range mentionedIDs { + a.Entities = append(a.Entities, entity{Type: "mention", Text: "x", Mentioned: channelAccount{ID: id}}) + } + return mustJSON(a) +} + +// TestDispatch_IgnoresOtherMentionInEngagedThread covers AE2 (R7 filter): once a +// thread is engaged, a message @mentioning only other people is human-to-human +// side chatter and is ignored, while a message mentioning others AND the bot is +// still forwarded. +func TestDispatch_IgnoresOtherMentionInEngagedThread(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + + p.dispatch(nil, messageActivity("conv-A", "user-1", "hey bot", true)) // engage + if len(*got) != 1 { + t.Fatalf("mention should engage and forward, got %d", len(*got)) + } + // Mentions user-2 only (not the bot) -> ignored despite the thread being engaged. + p.dispatch(nil, messageMentioning("conv-A", "user-1", "user-2")) + if len(*got) != 1 { + t.Fatalf("message mentioning only another user must be ignored, got %d", len(*got)) + } + // Mentions user-2 AND the bot -> forwarded (enters the session). + p.dispatch(nil, messageMentioning("conv-A", "user-1", "user-2", "bot-1")) + if len(*got) != 2 { + t.Fatalf("message mentioning another user and the bot must be forwarded, got %d", len(*got)) + } +} + +// TestDispatch_SelfMessageIgnored covers the R7 self-message guard: an activity +// authored by the bot itself never triggers a turn, even in an engaged thread. +func TestDispatch_SelfMessageIgnored(t *testing.T) { + p := teamsPlatform("thread") + h, got := collector() + p.handler = h + + p.dispatch(nil, messageActivity("conv-A", "user-1", "hey bot", true)) // engage + self := activity{ + Type: "message", + ID: "act-self", + Text: "echo", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "bot-1"}, // the bot's own appID + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "conv-A"}, + } + p.dispatch(nil, mustJSON(self)) + if len(*got) != 1 { + t.Fatalf("bot self-message must be ignored, got %d", len(*got)) + } +} diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go new file mode 100644 index 000000000..8fccb7d50 --- /dev/null +++ b/platform/teams/streaming_card.go @@ -0,0 +1,114 @@ +package teams + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// CreateStreamingCard implements core.StreamingCardPlatform: the reply always +// streams as an Adaptive Card, edited in place as the answer grows. It renders +// uniformly in channels, group chats, and 1:1 (unlike the native streamType +// protocol, which is one-on-one only). If the reply context is unusable the +// engine treats the error as "no stream" and falls back to a plain reply. +func (p *Platform) CreateStreamingCard(ctx context.Context, rctx any) (core.StreamingCard, error) { + rc, ok := rctx.(replyContext) + if !ok || rc.serviceURL == "" || rc.conversationID == "" { + return nil, fmt.Errorf("teams: invalid reply context for streaming card") + } + return p.createCardStream(ctx, rc) +} + +// streamInterval resolves the streaming-card edit cadence from +// card_update_interval_ms, with the built-in default as fallback. +func (p *Platform) streamInterval() time.Duration { + ms := p.cfg.cardUpdateIntervalMS + if ms <= 0 { + ms = defaultCardUpdateIntervalMS // zero-value config (e.g. tests built without parseConfig) + } + return time.Duration(ms) * time.Millisecond +} + +// createCardStream posts the loading Adaptive Card immediately and returns a +// handle that edits it in place. +func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core.StreamingCard, error) { + id, err := p.conn.send(ctx, rc, aiCardActivity(rc, loadingCard(""))) + if err != nil { + return nil, err + } + if id == "" { + return nil, fmt.Errorf("teams: streaming card got no activity id") + } + return &teamsStreamingCard{ + conn: p.conn, + rc: rc, + activityID: id, + interval: p.streamInterval(), + }, nil +} + +// teamsStreamingCard edits one Adaptive Card in place as the answer streams. +type teamsStreamingCard struct { + conn sender + rc replyContext + activityID string + interval time.Duration + + mu sync.Mutex + lastSent time.Time + lastText string + failed bool +} + +// Update edits the card with the answer so far, throttled (latest-wins). Mid-stream +// edit failures are non-fatal — Finalize is what gates the engine's fallback. +func (c *teamsStreamingCard) Update(ctx context.Context, content string) error { + c.mu.Lock() + if c.failed || content == c.lastText { + c.mu.Unlock() + return nil + } + if !c.lastSent.IsZero() && time.Since(c.lastSent) < c.interval { + c.lastText = content // remember the latest; a later Update past the interval sends it. + // Finalize does not read lastText — the engine passes it the full final content. + c.mu.Unlock() + return nil + } + c.lastSent = time.Now() + c.lastText = content + c.mu.Unlock() + + if err := c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, answerCard(content))); err != nil { + slog.Debug("teams: streaming card update failed", "error", err) + } + return nil +} + +// Finalize replaces the card with the final answer (plus the AI footer). A failure +// here marks the card failed so the engine sends its normal-message fallback. +func (c *teamsStreamingCard) Finalize(ctx context.Context, content string) error { + c.mu.Lock() + if c.failed { + c.mu.Unlock() + return nil // already terminal — don't re-PUT (matches Slack/DingTalk) + } + c.mu.Unlock() + if err := c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, answerCard(content))); err != nil { + c.mu.Lock() + c.failed = true + c.mu.Unlock() + return err + } + return nil +} + +// Failed reports whether the card hit a terminal error. +func (c *teamsStreamingCard) Failed() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.failed +} diff --git a/platform/teams/streaming_card_test.go b/platform/teams/streaming_card_test.go new file mode 100644 index 000000000..b4dbeb436 --- /dev/null +++ b/platform/teams/streaming_card_test.go @@ -0,0 +1,155 @@ +package teams + +import ( + "context" + "testing" + "time" +) + +func cardCtx() replyContext { + return replyContext{serviceURL: "https://s/", conversationID: "c1", sessionKey: "c1"} +} + +func TestCreateStreamingCard_InvalidContextReturnsError(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + // Missing serviceURL/conversationID -> engine treats the error as "no stream" + // and falls back to a plain reply. Nothing is posted. + card, err := p.CreateStreamingCard(context.Background(), replyContext{sessionKey: "c1"}) + if err == nil || card != nil { + t.Fatalf("invalid reply context must return (nil, error), got (%v, %v)", card, err) + } + if fs.last.Type != "" { + t.Error("invalid context must post nothing") + } +} + +func TestCreateStreamingCard_PostsLoadingCard(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + card, err := p.CreateStreamingCard(context.Background(), cardCtx()) + if err != nil || card == nil { + t.Fatalf("card mode should create a card, got (%v, %v)", card, err) + } + if len(fs.last.Attachments) != 1 || fs.last.Attachments[0].ContentType != adaptiveCardContentType { + t.Fatalf("loading card not posted as an adaptive-card attachment: %+v", fs.last) + } + if len(fs.last.Entities) != 1 { + t.Errorf("loading card should carry the AI label entity from the start, got %+v", fs.last.Entities) + } +} + +func TestCreateStreamingCard_ChannelContextPostsCard(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + // A channel reply-thread conversation id must stream identically to 1:1. + rc := replyContext{serviceURL: "https://s/", conversationID: "19:abc@thread.tacv2;messageid=1", sessionKey: "k"} + card, err := p.CreateStreamingCard(context.Background(), rc) + if err != nil || card == nil { + t.Fatalf("channel context should create a card stream, got (%v, %v)", card, err) + } + if _, ok := card.(*teamsStreamingCard); !ok { + t.Fatalf("want *teamsStreamingCard, got %T", card) + } + if len(fs.last.Attachments) != 1 || fs.last.Attachments[0].ContentType != adaptiveCardContentType { + t.Fatalf("channel card should post a loading adaptive card, got %+v", fs.last) + } +} + +func TestCreateStreamingCard_UsesConfiguredInterval(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{cardUpdateIntervalMS: 900}} + card, err := p.CreateStreamingCard(context.Background(), cardCtx()) + if err != nil { + t.Fatalf("CreateStreamingCard: %v", err) + } + tc, ok := card.(*teamsStreamingCard) + if !ok { + t.Fatalf("unexpected card type %T", card) + } + if tc.interval != 900*time.Millisecond { + t.Errorf("interval = %v, want 900ms", tc.interval) + } +} + +func TestCreateStreamingCard_DefaultIntervalWhenUnset(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} // cardUpdateIntervalMS == 0 + card, _ := p.CreateStreamingCard(context.Background(), cardCtx()) + tc := card.(*teamsStreamingCard) + if tc.interval != defaultCardUpdateIntervalMS*time.Millisecond { + t.Errorf("interval = %v, want default %dms", tc.interval, defaultCardUpdateIntervalMS) + } +} + +func TestStreamingCard_UpdateEditsInPlace(t *testing.T) { + fs := &fakeSender{} + c := &teamsStreamingCard{conn: fs, rc: cardCtx(), activityID: "m1", interval: 0} + if err := c.Update(context.Background(), "hello"); err != nil { + t.Fatalf("Update: %v", err) + } + if len(fs.updatedIDs) != 1 || fs.updatedIDs[0] != "m1" { + t.Fatalf("expected one in-place edit of m1, got %v", fs.updatedIDs) + } + if len(fs.updates[0].Attachments) != 1 { + t.Errorf("update should carry the card attachment: %+v", fs.updates[0]) + } + if len(fs.updates[0].Entities) != 1 { + t.Errorf("streaming update should also carry the AI label entity, got %+v", fs.updates[0].Entities) + } +} + +func TestStreamingCard_UpdateThrottles(t *testing.T) { + fs := &fakeSender{} + c := &teamsStreamingCard{conn: fs, rc: cardCtx(), activityID: "m1", interval: time.Hour} + _ = c.Update(context.Background(), "one") // first edit bypasses throttle + _ = c.Update(context.Background(), "two") // within interval -> skipped + _ = c.Update(context.Background(), "three") // skipped + if len(fs.updates) != 1 { + t.Errorf("throttle should coalesce to 1 edit, got %d", len(fs.updates)) + } +} + +func TestStreamingCard_FinalizeAttachesAILabel(t *testing.T) { + fs := &fakeSender{} + c := &teamsStreamingCard{conn: fs, rc: cardCtx(), activityID: "m1", interval: time.Hour} + if err := c.Finalize(context.Background(), "the answer"); err != nil { + t.Fatalf("Finalize: %v", err) + } + if len(fs.updates) == 0 { + t.Fatal("no final update sent") + } + last := fs.updates[len(fs.updates)-1] + + // Body is a single text block — no "Generated by AI" footer in the card body. + card, _ := last.Attachments[0].Content.(map[string]any) + if body, _ := card["body"].([]map[string]any); len(body) != 1 { + t.Errorf("final card body should be one block (no footer), got %d", len(body)) + } + // The native AI label rides on the message as an entity. + if len(last.Entities) != 1 || last.Entities[0]["additionalType"] == nil { + t.Errorf("final message should carry the AIGeneratedContent entity, got %+v", last.Entities) + } +} + +func TestStreamingCard_FinalizeFailureMarksFailed(t *testing.T) { + fs := &fakeSender{err: context.DeadlineExceeded} + c := &teamsStreamingCard{conn: fs, rc: cardCtx(), activityID: "m1", interval: time.Hour} + if err := c.Finalize(context.Background(), "x"); err == nil { + t.Fatal("expected Finalize to return the edit error") + } + if !c.Failed() { + t.Error("Finalize failure should mark the card Failed (engine falls back)") + } +} + +func TestStreamingCard_UpdateErrorIsNonFatal(t *testing.T) { + fs := &fakeSender{err: context.DeadlineExceeded} + c := &teamsStreamingCard{conn: fs, rc: cardCtx(), activityID: "m1", interval: 0} + if err := c.Update(context.Background(), "x"); err != nil { + t.Errorf("Update must be best-effort, got %v", err) + } + if c.Failed() { + t.Error("a mid-stream update error must not mark the card failed") + } +} diff --git a/platform/teams/teams.go b/platform/teams/teams.go new file mode 100644 index 000000000..980c90751 --- /dev/null +++ b/platform/teams/teams.go @@ -0,0 +1,143 @@ +// Package teams implements a native cc-connect platform for Microsoft Teams. +// +// Teams differs from the outbound-connection platforms (Slack socket mode, +// Feishu/WeCom websockets): the Bot Framework delivers activities by POSTing to +// a public HTTPS webhook, and there is no Microsoft Go SDK. This package +// terminates the Bot Framework itself — inbound JWT validation, the outbound Bot +// Connector REST client with AAD auth, and the Teams streaming-message protocol. +package teams + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterPlatform("teams", New) +} + +// Platform is the cc-connect Teams connector. +type Platform struct { + cfg config + handler core.MessageHandler + + validator *inboundValidator + engaged *engagement + conn sender + server *http.Server +} + +// sender abstracts the Bot Connector calls for testability. +type sender interface { + send(ctx context.Context, rc replyContext, a outboundActivity) (string, error) + replyTo(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error + update(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error +} + +// Optional-interface assertions: the engine type-switches on these to drive +// streaming preview and prompt formatting. +var ( + _ core.ReplyContextReconstructor = (*Platform)(nil) + _ core.FormattingInstructionProvider = (*Platform)(nil) + _ core.StreamingCardPlatform = (*Platform)(nil) +) + +// New builds a Teams platform from the config.toml options table. +func New(opts map[string]any) (core.Platform, error) { + cfg, err := parseConfig(opts) + if err != nil { + return nil, err + } + core.CheckAllowFrom("teams", cfg.allowFrom) + return &Platform{ + cfg: cfg, + engaged: newEngagement(engagementPath(cfg.dataDir, cfg.project)), + }, nil +} + +func (p *Platform) Name() string { return "teams" } + +// Start brings up the inbound webhook server: it sets up the JWT validator +// (fetching the Bot Framework JWKS) and listens for Bot Connector activity POSTs. +func (p *Platform) Start(handler core.MessageHandler) error { + p.handler = handler + if p.validator == nil { + v, err := newInboundValidator(p.cfg) + if err != nil { + return err + } + p.validator = v + } + if p.conn == nil { + p.conn = newConnector(newTokenSource(p.cfg)) + } + mux := http.NewServeMux() + mux.HandleFunc(p.cfg.webhookPath, p.handleActivity) + // Timeouts on a public-facing listener guard against slowloris-style clients. + p.server = &http.Server{ + Addr: ":" + p.cfg.webhookPort, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + go func() { + slog.Info("teams: webhook listening", "port", p.cfg.webhookPort, "path", p.cfg.webhookPath) + if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("teams: webhook server error", "error", err) + } + }() + return nil +} + +// Reply posts a message threaded to the originating activity via the Bot +// Framework reply-to-activity endpoint (threading is keyed by the endpoint, not +// a body field). Falls back to a plain send when there is no activity to thread. +func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error { + rc, ok := replyCtx.(replyContext) + if !ok { + return fmt.Errorf("teams: invalid reply context %T", replyCtx) + } + if rc.activityID == "" { + _, err := p.conn.send(ctx, rc, newMessageActivity(rc, content)) + return err + } + return p.conn.replyTo(ctx, rc, rc.activityID, newMessageActivity(rc, content)) +} + +// Send posts a new message activity into the conversation. +func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error { + rc, ok := replyCtx.(replyContext) + if !ok { + return fmt.Errorf("teams: invalid reply context %T", replyCtx) + } + _, err := p.conn.send(ctx, rc, newMessageActivity(rc, content)) + return err +} + +// ReconstructReplyCtx rebuilds a reply context from a session key. Only the +// conversation id is recoverable from the key; the per-activity serviceURL is +// not encoded, so proactive sends (cron→Teams) need a conversation-reference +// store — deferred follow-up. Implementing this satisfies the optional +// ReplyContextReconstructor interface used by the engine. +func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { + conv, err := conversationFromSessionKey(sessionKey) + if err != nil { + return nil, err + } + return replyContext{conversationID: conv}, nil +} + +// Stop shuts down the webhook server. +func (p *Platform) Stop() error { + if p.server != nil { + return p.server.Shutdown(context.Background()) + } + return nil +} diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go new file mode 100644 index 000000000..fac7f533b --- /dev/null +++ b/platform/teams/webhook.go @@ -0,0 +1,139 @@ +package teams + +import ( + "io" + "log/slog" + "net/http" + "strings" + + "github.com/chenhg5/cc-connect/core" + "github.com/golang-jwt/jwt/v5" +) + +// maxBodyBytes caps the activity payload read from the connector. +const maxBodyBytes = 1 << 20 // 1 MiB + +// handleActivity is the Bot Connector webhook entry point. It authenticates the +// request before reading the body, then hands the activity to the engine. +func (p *Platform) handleActivity(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + token := bearerToken(r) + if token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + claims, err := p.validator.validate(token) + if err != nil { + slog.Warn("teams: rejected activity", "error", err) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + p.dispatch(claims, body) + + w.WriteHeader(http.StatusOK) +} + +// dispatch parses an activity, enforces serviceURL binding + authorization + +// the engagement gate, and forwards a core.Message to the engine. Gated-out or +// malformed activities are dropped silently (the webhook still returns 200). +func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { + a, err := parseActivity(body) + if err != nil { + slog.Warn("teams: bad activity payload", "error", err) + return + } + if !strings.EqualFold(a.Type, "message") { + return // ignore typing/conversationUpdate/etc. + } + + // serviceURL binding: a genuine Bot Framework token carries the issuer's + // serviceurl claim. Rejecting a body whose serviceUrl doesn't match prevents + // a replayed valid token from redirecting the bot's authenticated replies + // (and the bearer token they carry) to an attacker-controlled host. + if !serviceURLClaimMatches(claims, a.ServiceURL) { + slog.Warn("teams: serviceUrl claim mismatch; dropping activity") + return + } + + action := a.cardAction() + isCardAction := action != "" + content := a.cleanText() + if content == "" && !isCardAction { + return // empty message with no card action + } + // Authorize before touching engagement so an unauthorized @mention cannot + // flip a conversation into the engaged set. + if !core.AllowList(p.cfg.allowFrom, userID(a)) { + slog.Debug("teams: message from unauthorized user", "user", userID(a)) + return + } + if !p.shouldHandle(a, isCardAction) { + return + } + + sessionKey := p.sessionKey(a) + msg := &core.Message{ + SessionKey: sessionKey, + Platform: "teams", + MessageID: a.ID, + ChannelID: a.Conversation.ID, + UserID: userID(a), + UserName: a.From.Name, + ChatName: a.Conversation.Name, + ReplyCtx: replyContext{ + serviceURL: a.ServiceURL, + conversationID: a.Conversation.ID, + sessionKey: sessionKey, + activityID: a.ID, + botAccount: a.Recipient, + userAccount: a.From, + }, + } + if isCardAction { + msg.Content = action + msg.IsPermissionResponse = true + } else { + msg.Content = content + } + p.handler(p, msg) +} + +// serviceURLClaimMatches reports whether the token's serviceurl claim matches +// the activity serviceUrl. When the claim is absent (e.g. the Bot Framework +// Emulator), it does not block — a replayed real channel token always carries +// the claim, which is what the binding defends against. +func serviceURLClaimMatches(claims jwt.MapClaims, activityServiceURL string) bool { + claimed, ok := claims["serviceurl"].(string) + if !ok || claimed == "" { + return true + } + return strings.TrimRight(claimed, "/") == strings.TrimRight(activityServiceURL, "/") +} + +// userID prefers the stable AAD object ID, falling back to the channel-scoped ID. +func userID(a *activity) string { + if a.From.AADObjectID != "" { + return a.From.AADObjectID + } + return a.From.ID +} + +// bearerToken extracts the token from an "Authorization: Bearer " header. +func bearerToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(auth[len(prefix):]) +} diff --git a/platform/teams/webhook_test.go b/platform/teams/webhook_test.go new file mode 100644 index 000000000..b3fdde4e8 --- /dev/null +++ b/platform/teams/webhook_test.go @@ -0,0 +1,111 @@ +package teams + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testPlatform(s *testSigner) *Platform { + return &Platform{cfg: config{appID: "app-123"}, validator: validatorWith(s)} +} + +func TestHandleActivity_ValidTokenAccepted(t *testing.T) { + s := newTestSigner(t) + p := testPlatform(s) + + req := httptest.NewRequest(http.MethodPost, "/api/messages", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer "+s.sign(t, baseClaims())) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestHandleActivity_MissingAuthRejected(t *testing.T) { + s := newTestSigner(t) + p := testPlatform(s) + + req := httptest.NewRequest(http.MethodPost, "/api/messages", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestHandleActivity_BadTokenRejected(t *testing.T) { + s := newTestSigner(t) + other := newTestSigner(t) + p := testPlatform(s) + + req := httptest.NewRequest(http.MethodPost, "/api/messages", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer "+other.sign(t, baseClaims())) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestHandleActivity_NonPostRejected(t *testing.T) { + s := newTestSigner(t) + p := testPlatform(s) + + req := httptest.NewRequest(http.MethodGet, "/api/messages", nil) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rec.Code) + } +} + +type errReader struct{} + +func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +func TestHandleActivity_BodyReadError(t *testing.T) { + s := newTestSigner(t) + p := testPlatform(s) + + req := httptest.NewRequest(http.MethodPost, "/api/messages", errReader{}) + req.Header.Set("Authorization", "Bearer "+s.sign(t, baseClaims())) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 on body read error", rec.Code) + } +} + +func TestBearerToken(t *testing.T) { + cases := map[string]string{ + "Bearer abc": "abc", + "bearer abc": "abc", + "abc": "", + "": "", + "Bearer ": "", + "Basic xyz": "", + } + for header, want := range cases { + req := httptest.NewRequest(http.MethodPost, "/", nil) + if header != "" { + req.Header.Set("Authorization", header) + } + if got := bearerToken(req); got != want { + t.Errorf("bearerToken(%q) = %q, want %q", header, got, want) + } + } +} From cdabdcab48e8017a1237e204f20f9f6f0f8e82f2 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Mon, 6 Jul 2026 15:07:03 +0200 Subject: [PATCH 02/34] fix(review): teams connector code-review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound the AAD token fetch with a timeout-bearing HTTP client — it runs before the connector's own http timeout, so an unreachable login endpoint could otherwise hang a turn goroutine indefinitely. - Give the webhook server a bounded shutdown context. - Fix the self-message guard to match the bot's "28:" channelAccount form (the bare-appID compare never matched); harden its test accordingly. - Remove dead fields: activity.ReplyToID and replyContext.sessionKey. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/activity.go | 1 - platform/teams/auth_outbound.go | 10 ++++++++-- platform/teams/session.go | 13 ++++++------- platform/teams/session_test.go | 4 ++-- platform/teams/streaming_card_test.go | 6 +++--- platform/teams/teams.go | 4 +++- platform/teams/webhook.go | 1 - 7 files changed, 22 insertions(+), 17 deletions(-) diff --git a/platform/teams/activity.go b/platform/teams/activity.go index f0d544d81..1135bcb93 100644 --- a/platform/teams/activity.go +++ b/platform/teams/activity.go @@ -12,7 +12,6 @@ type activity struct { ID string `json:"id"` Text string `json:"text"` ServiceURL string `json:"serviceUrl"` - ReplyToID string `json:"replyToId"` From channelAccount `json:"from"` Recipient channelAccount `json:"recipient"` Conversation conversationAccount `json:"conversation"` diff --git a/platform/teams/auth_outbound.go b/platform/teams/auth_outbound.go index c34eb6e8a..87c499318 100644 --- a/platform/teams/auth_outbound.go +++ b/platform/teams/auth_outbound.go @@ -3,6 +3,7 @@ package teams import ( "context" "fmt" + "net/http" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" @@ -42,8 +43,13 @@ func newTokenSourceWithURL(cfg config, url string) *oauthTokenSource { Scopes: []string{connectorScope}, AuthStyle: oauth2.AuthStyleInParams, } - // clientcredentials.Config.TokenSource caches and refreshes internally. - return &oauthTokenSource{src: conf.TokenSource(context.Background())} + // Bound the token fetch with a timeout-bearing HTTP client — connector.do + // acquires the token before its own http.Client (with connectorTimeout) runs, + // so without this an unreachable login.microsoftonline.com would hang the turn + // goroutine indefinitely. clientcredentials.Config.TokenSource caches and + // refreshes internally. + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: connectorTimeout}) + return &oauthTokenSource{src: conf.TokenSource(ctx)} } func (o *oauthTokenSource) token(ctx context.Context) (string, error) { diff --git a/platform/teams/session.go b/platform/teams/session.go index 0ad972a67..1bba72889 100644 --- a/platform/teams/session.go +++ b/platform/teams/session.go @@ -19,11 +19,9 @@ import ( type replyContext struct { serviceURL string conversationID string - // sessionKey is the engine turn key, derived per session_scope (see sessionKey). - sessionKey string - activityID string // the inbound activity id, used as replyToId - botAccount channelAccount // the bot (inbound recipient) — outbound `from` - userAccount channelAccount // the user (inbound from) — outbound `recipient` + activityID string // the inbound activity id, used as the reply-to-activity target + botAccount channelAccount // the bot (inbound recipient) — outbound `from` + userAccount channelAccount // the user (inbound from) — outbound `recipient` } // engagement tracks which conversations the bot has joined. A bot @mention @@ -167,8 +165,9 @@ func conversationFromSessionKey(key string) (string, error) { func (p *Platform) shouldHandle(a *activity, isCardAction bool) bool { // Belt-and-suspenders: never act on the bot's own activity. Bot Framework does // not redeliver a bot's outbound messages, but a multi-bot install or platform - // echo must not be able to self-trigger a loop. - if p.cfg.appID != "" && a.From.ID == p.cfg.appID { + // echo must not be able to self-trigger a loop. A bot's channelAccount ID is the + // "28:" form, not the bare app ID, so match on the suffix. + if p.cfg.appID != "" && strings.HasSuffix(a.From.ID, p.cfg.appID) { return false } if isCardAction { diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index c2d3d6f8b..5965319a4 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -399,8 +399,8 @@ func TestDispatch_SelfMessageIgnored(t *testing.T) { ID: "act-self", Text: "echo", ServiceURL: "https://smba.example/", - From: channelAccount{ID: "bot-1"}, // the bot's own appID - Recipient: channelAccount{ID: "bot-1"}, + From: channelAccount{ID: "28:bot-1"}, // a bot's channelAccount ID is the "28:" form + Recipient: channelAccount{ID: "28:bot-1"}, Conversation: conversationAccount{ID: "conv-A"}, } p.dispatch(nil, mustJSON(self)) diff --git a/platform/teams/streaming_card_test.go b/platform/teams/streaming_card_test.go index b4dbeb436..74516b028 100644 --- a/platform/teams/streaming_card_test.go +++ b/platform/teams/streaming_card_test.go @@ -7,7 +7,7 @@ import ( ) func cardCtx() replyContext { - return replyContext{serviceURL: "https://s/", conversationID: "c1", sessionKey: "c1"} + return replyContext{serviceURL: "https://s/", conversationID: "c1"} } func TestCreateStreamingCard_InvalidContextReturnsError(t *testing.T) { @@ -15,7 +15,7 @@ func TestCreateStreamingCard_InvalidContextReturnsError(t *testing.T) { p := &Platform{conn: fs, cfg: config{}} // Missing serviceURL/conversationID -> engine treats the error as "no stream" // and falls back to a plain reply. Nothing is posted. - card, err := p.CreateStreamingCard(context.Background(), replyContext{sessionKey: "c1"}) + card, err := p.CreateStreamingCard(context.Background(), replyContext{}) if err == nil || card != nil { t.Fatalf("invalid reply context must return (nil, error), got (%v, %v)", card, err) } @@ -43,7 +43,7 @@ func TestCreateStreamingCard_ChannelContextPostsCard(t *testing.T) { fs := &fakeSender{id: "m1"} p := &Platform{conn: fs, cfg: config{}} // A channel reply-thread conversation id must stream identically to 1:1. - rc := replyContext{serviceURL: "https://s/", conversationID: "19:abc@thread.tacv2;messageid=1", sessionKey: "k"} + rc := replyContext{serviceURL: "https://s/", conversationID: "19:abc@thread.tacv2;messageid=1"} card, err := p.CreateStreamingCard(context.Background(), rc) if err != nil || card == nil { t.Fatalf("channel context should create a card stream, got (%v, %v)", card, err) diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 980c90751..253691316 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -137,7 +137,9 @@ func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) { // Stop shuts down the webhook server. func (p *Platform) Stop() error { if p.server != nil { - return p.server.Shutdown(context.Background()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return p.server.Shutdown(ctx) } return nil } diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index fac7f533b..f716146e1 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -93,7 +93,6 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { ReplyCtx: replyContext{ serviceURL: a.ServiceURL, conversationID: a.Conversation.ID, - sessionKey: sessionKey, activityID: a.ID, botAccount: a.Recipient, userAccount: a.From, From 58e33b2da147b15c77ef134206dfe34ef5c75019 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Mon, 6 Jul 2026 15:30:00 +0200 Subject: [PATCH 03/34] feat(teams): async webhook ack + optional serviceURL host allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ack 202 and run the agent turn on a background goroutine (JWT validation stays synchronous before the ack). A slow turn under a synchronous ack could exceed Bot Framework's ~15s window and trigger a retry + duplicate dispatch; acking first matches the M365 Agents SDK's background-queue model. - Add an optional service_url_allowlist (comma-separated hosts, empty = any JWT-validated host). When set, an activity whose serviceUrl host is off the list is dropped before the bot's bearer token is POSTed to it — defense in depth against a forged serviceURL. Host-based match so regional paths pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- config.example.toml | 3 ++ docs/teams.md | 24 ++++++++++++++ platform/teams/config.go | 58 ++++++++++++++++++++++++++++------ platform/teams/config_test.go | 45 ++++++++++++++++++++++++++ platform/teams/session_test.go | 23 ++++++++++++++ platform/teams/webhook.go | 18 +++++++++-- platform/teams/webhook_test.go | 5 +-- 7 files changed, 162 insertions(+), 14 deletions(-) diff --git a/config.example.toml b/config.example.toml index 23478df41..14e58db50 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1203,6 +1203,9 @@ app_secret = "your-feishu-app-secret" # allow_from = "*" # "*"/empty = all users in the tenant; or comma-separated AAD object IDs / "*"/留空=租户内所有人,或逗号分隔的 AAD 用户 ID # session_scope = "thread" # "thread" (default) | "channel" | "user" # card_update_interval_ms = 1500 # streaming card edit throttle (ms; Teams limits edits ~1/s) +# service_url_allowlist = "" # optional defense-in-depth: comma-separated Bot Connector hosts the +# # bot may reply to; empty = any JWT-validated host. e.g. public cloud: +# # "smba.trafficmanager.net" (see docs/teams.md for gov-cloud hosts) # MAX messenger (uncomment to enable / 取消注释以启用) # 1. Create a bot via @MasterBot in MAX, get the access token diff --git a/docs/teams.md b/docs/teams.md index 51b61f591..527a8ee77 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -86,6 +86,30 @@ message — Teams does not allow @mentioning a bot there. | `allow_from` | no | `""` | Comma-separated AAD object IDs allowed to use the bot; `*` or empty = all users in the tenant | | `session_scope` | no | `thread` | `thread` (one session per reply thread), `channel` (one shared session across the channel), `user` (one session per user within a thread) | | `card_update_interval_ms` | no | `1500` | Streaming-card edit throttle in ms; Teams rate-limits edits to ~1/s | +| `service_url_allowlist` | no | `""` | Comma-separated hosts the bot may send replies to. Empty = any JWT-validated host (default). Set it to pin the bot to your cloud's Bot Connector host(s) as defense-in-depth. See "serviceURL allowlist" below | + +## serviceURL allowlist + +The bot sends its replies (carrying its Bot Connector bearer token) to the +`serviceUrl` from each inbound activity. That URL is already bound to a +JWT-validated request, so by default any authenticated `serviceUrl` is trusted — +the same model as the Bot Framework / M365 Agents SDK. + +For defense-in-depth (or a compliance lockdown), set `service_url_allowlist` to +the Bot Connector host(s) your tenant's cloud uses; the bot then drops any +activity whose `serviceUrl` host is not listed. Matching is on **host** (so +regional paths like `/amer/`, `/emea/` are fine). Current Microsoft hosts for +reference (verify against Azure docs — this list can change): + +| Cloud | Host | +|-------|------| +| Public | `smba.trafficmanager.net` | +| GCC | `smba.infra.gcc.teams.microsoft.com` | +| GCC High | `smba.infra.gov.teams.microsoft.us` | +| DoD | `smba.infra.dod.teams.microsoft.us` | + +Leave it empty unless you have a reason to pin — a too-narrow list silently drops +legitimate traffic. ## Connection type diff --git a/platform/teams/config.go b/platform/teams/config.go index 8bb19a0d1..dcdb0fd05 100644 --- a/platform/teams/config.go +++ b/platform/teams/config.go @@ -3,6 +3,7 @@ package teams import ( "fmt" "log/slog" + "net/url" "strings" ) @@ -35,6 +36,12 @@ type config struct { allowFrom string sessionScope string // "thread" (default) | "channel" | "user" + // serviceURLAllowlist restricts the outbound Bot Connector serviceURL to these + // hosts (defense-in-depth against a forged serviceURL exfiltrating the bot + // token). Empty (default) = allow any JWT-validated serviceURL, matching the + // Bot Framework / M365 Agents SDK, which trust the authenticated inbound host. + serviceURLAllowlist []string + cardUpdateIntervalMS int // card edit throttle (ms); smaller = finer chunks (floor ~1s) // dataDir and project are injected by cc-connect (cc_data_dir / cc_project) @@ -47,15 +54,16 @@ type config struct { // parseConfig extracts and validates the Teams config from the platform opts map. func parseConfig(opts map[string]any) (config, error) { c := config{ - appID: strings.TrimSpace(stringOpt(opts, "app_id")), - appPassword: stringOpt(opts, "app_password"), - tenantID: strings.TrimSpace(stringOpt(opts, "tenant_id")), - webhookPort: strings.TrimSpace(stringOpt(opts, "webhook_port")), - webhookPath: strings.TrimSpace(stringOpt(opts, "webhook_path")), - allowFrom: stringOpt(opts, "allow_from"), - sessionScope: normalizeSessionScope(opts["session_scope"]), - dataDir: stringOpt(opts, "cc_data_dir"), - project: stringOpt(opts, "cc_project"), + appID: strings.TrimSpace(stringOpt(opts, "app_id")), + appPassword: stringOpt(opts, "app_password"), + tenantID: strings.TrimSpace(stringOpt(opts, "tenant_id")), + webhookPort: strings.TrimSpace(stringOpt(opts, "webhook_port")), + webhookPath: strings.TrimSpace(stringOpt(opts, "webhook_path")), + allowFrom: stringOpt(opts, "allow_from"), + sessionScope: normalizeSessionScope(opts["session_scope"]), + serviceURLAllowlist: splitCSV(stringOpt(opts, "service_url_allowlist")), + dataDir: stringOpt(opts, "cc_data_dir"), + project: stringOpt(opts, "cc_project"), } c.cardUpdateIntervalMS = intOpt(opts, "card_update_interval_ms", defaultCardUpdateIntervalMS) if c.cardUpdateIntervalMS <= 0 { @@ -89,6 +97,38 @@ func stringOpt(opts map[string]any, key string) string { return s } +// splitCSV splits a comma-separated option into trimmed, non-empty entries, +// returning nil when the option is empty. +func splitCSV(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +// serviceURLAllowed reports whether the activity serviceURL's host is permitted. +// An empty allowlist permits any host (the serviceURL is already bound to a +// JWT-validated request); otherwise the parsed host must match an entry +// case-insensitively. An unparseable URL with a non-empty allowlist is rejected. +func serviceURLAllowed(rawURL string, allowlist []string) bool { + if len(allowlist) == 0 { + return true + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return false + } + for _, h := range allowlist { + if strings.EqualFold(u.Host, h) { + return true + } + } + return false +} + // intOpt reads an integer option, returning def when absent or the wrong type. // TOML/JSON decoders surface numbers as int64 or float64, so accept both. func intOpt(opts map[string]any, key string, def int) int { diff --git a/platform/teams/config_test.go b/platform/teams/config_test.go index 11f5de1bf..a7130301b 100644 --- a/platform/teams/config_test.go +++ b/platform/teams/config_test.go @@ -118,6 +118,51 @@ func TestParseConfig_CardUpdateIntervalMS(t *testing.T) { } } +func TestParseConfig_ServiceURLAllowlist(t *testing.T) { + // absent -> nil (allow any) + c, _ := parseConfig(validOpts()) + if c.serviceURLAllowlist != nil { + t.Errorf("absent service_url_allowlist -> %v, want nil", c.serviceURLAllowlist) + } + // comma-separated, trimmed, empties dropped + opts := validOpts() + opts["service_url_allowlist"] = " smba.trafficmanager.net , , smba.infra.gcc.teams.microsoft.com " + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"smba.trafficmanager.net", "smba.infra.gcc.teams.microsoft.com"} + if len(c.serviceURLAllowlist) != len(want) { + t.Fatalf("allowlist = %v, want %v", c.serviceURLAllowlist, want) + } + for i, h := range want { + if c.serviceURLAllowlist[i] != h { + t.Errorf("allowlist[%d] = %q, want %q", i, c.serviceURLAllowlist[i], h) + } + } +} + +func TestServiceURLAllowed(t *testing.T) { + list := []string{"smba.trafficmanager.net"} + cases := []struct { + name string + url string + allowlist []string + want bool + }{ + {"empty allowlist allows any", "https://evil.example/x", nil, true}, + {"host matches (regional path)", "https://smba.trafficmanager.net/amer/v3/", list, true}, + {"host match is case-insensitive", "https://SMBA.TrafficManager.net/emea/", list, true}, + {"foreign host rejected", "https://attacker.example/v3/", list, false}, + {"unparseable url rejected when allowlist set", "://nope", list, false}, + } + for _, tc := range cases { + if got := serviceURLAllowed(tc.url, tc.allowlist); got != tc.want { + t.Errorf("%s: serviceURLAllowed(%q) = %v, want %v", tc.name, tc.url, got, tc.want) + } + } +} + func TestNew_RegistersAsPlatform(t *testing.T) { p, err := New(validOpts()) if err != nil { diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index 5965319a4..11917c238 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -314,6 +314,29 @@ func TestDispatch_AllowListBlocks(t *testing.T) { } } +// TestDispatch_ServiceURLAllowlist covers the optional host allowlist: an activity +// whose serviceURL host is not on the list is dropped before any outbound call. +func TestDispatch_ServiceURLAllowlist(t *testing.T) { + // messageActivity sets serviceURL "https://smba.example/" (host smba.example). + allowed := teamsPlatform("thread") + allowed.cfg.serviceURLAllowlist = []string{"smba.example"} + h, got := collector() + allowed.handler = h + allowed.dispatch(nil, messageActivity("conv-A", "user-1", "hi bot", true)) + if len(*got) != 1 { + t.Fatalf("allowlisted serviceURL host should be handled, got %d", len(*got)) + } + + blocked := teamsPlatform("thread") + blocked.cfg.serviceURLAllowlist = []string{"smba.trafficmanager.net"} + h2, got2 := collector() + blocked.handler = h2 + blocked.dispatch(nil, messageActivity("conv-B", "user-1", "hi bot", true)) + if len(*got2) != 0 { + t.Fatalf("serviceURL host off the allowlist must be dropped, got %d", len(*got2)) + } +} + // TestSessionKey_ScopeVariants covers AE8: the three scopes produce distinct // keys, and channel scope collapses sibling threads to the channel root. func TestSessionKey_ScopeVariants(t *testing.T) { diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index f716146e1..6ca02276a 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -14,7 +14,11 @@ import ( const maxBodyBytes = 1 << 20 // 1 MiB // handleActivity is the Bot Connector webhook entry point. It authenticates the -// request before reading the body, then hands the activity to the engine. +// request and reads the body synchronously, then acks 202 and runs the agent turn +// on a background goroutine. Bot Framework expects a fast ack (~15s) and retries +// on timeout; a slow turn (e.g. a cold agent start) under a synchronous ack would +// trigger a retry and a duplicate dispatch. Acking before dispatch matches the +// M365 Agents SDK, which queues activities to a background worker and returns 202. func (p *Platform) handleActivity(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -38,9 +42,11 @@ func (p *Platform) handleActivity(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - p.dispatch(claims, body) - w.WriteHeader(http.StatusOK) + // Ack first, process the turn asynchronously. The card-only connector never + // returns Invoke/ExpectReplies responses, so nothing needs a synchronous body. + w.WriteHeader(http.StatusAccepted) + go p.dispatch(claims, body) } // dispatch parses an activity, enforces serviceURL binding + authorization + @@ -64,6 +70,12 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { slog.Warn("teams: serviceUrl claim mismatch; dropping activity") return } + // Optional host allowlist: reject a serviceURL outside the configured hosts + // before any outbound POST carries the bot's bearer token to it. Off by default. + if !serviceURLAllowed(a.ServiceURL, p.cfg.serviceURLAllowlist) { + slog.Warn("teams: serviceUrl host not in allowlist; dropping activity", "service_url", a.ServiceURL) + return + } action := a.cardAction() isCardAction := action != "" diff --git a/platform/teams/webhook_test.go b/platform/teams/webhook_test.go index b3fdde4e8..5896a79c8 100644 --- a/platform/teams/webhook_test.go +++ b/platform/teams/webhook_test.go @@ -22,8 +22,9 @@ func TestHandleActivity_ValidTokenAccepted(t *testing.T) { p.handleActivity(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) + // The turn is dispatched asynchronously; the webhook acks 202 immediately. + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202", rec.Code) } } From de221591174ea330989670fe27861206909fb3a0 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Mon, 6 Jul 2026 15:53:16 +0200 Subject: [PATCH 04/34] fix(teams): channel-issuer-only inbound + bounded dispatch concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security hardening from the Fable security re-analysis: - Accept only the Bot Framework channel issuer (api.botframework.com) for inbound activity tokens; drop the AAD tenant-issuer path. That path exists in the SDK for agent-to-agent/skill invocation, which this messaging connector does not offer. Restricting to the channel issuer keeps the activity `From` channel-signed, so allow_from and the serviceURL binding rest on a trusted identity instead of caller-supplied body fields (closes the spoof + fail-open residuals). tenant_id remains required for the outbound token authority. - Cap concurrent async dispatch with a semaphore (maxConcurrentDispatch); at capacity the webhook sheds with 503 so the Bot Connector retries, rather than spawning unbounded agent turns — matching the SDK's bounded background queue. Co-Authored-By: Claude Fable 5 --- platform/teams/auth_inbound.go | 70 ++++++++++------------------- platform/teams/auth_inbound_test.go | 37 +++++++-------- platform/teams/teams.go | 21 ++++++--- platform/teams/webhook.go | 19 ++++++-- platform/teams/webhook_test.go | 25 ++++++++++- 5 files changed, 94 insertions(+), 78 deletions(-) diff --git a/platform/teams/auth_inbound.go b/platform/teams/auth_inbound.go index 83d0452ca..8997e1fa2 100644 --- a/platform/teams/auth_inbound.go +++ b/platform/teams/auth_inbound.go @@ -18,56 +18,41 @@ const ( defaultLeeway = 5 * time.Minute ) -func aadJWKSURL(tenant string) string { - return fmt.Sprintf("https://login.microsoftonline.com/%s/discovery/v2.0/keys", tenant) -} - -// aadIssuers returns the AAD issuer strings accepted for a tenant, matching the -// SDK's ISSUERS list (sts.windows.net v1 and login.microsoftonline.com v2). -func aadIssuers(tenant string) []string { - return []string{ - fmt.Sprintf("https://sts.windows.net/%s/", tenant), - fmt.Sprintf("https://login.microsoftonline.com/%s/v2.0", tenant), - } -} - -// inboundValidator verifies Bot Framework activity JWTs. It selects the signing -// keys by issuer (Bot Framework vs the configured AAD tenant) and enforces -// RS256, audience == app ID, issuer trust, and expiry with leeway. +// inboundValidator verifies Bot Framework activity JWTs. It accepts only the Bot +// Framework channel issuer and enforces RS256, audience == app ID, and expiry +// with leeway. +// +// This connector is a Teams *messaging* bot: real user activities always arrive +// signed by the Bot Framework channel (iss = api.botframework.com). We do NOT +// accept tokens issued directly by the tenant's AAD (sts.windows.net / +// login.microsoftonline.com) — that path exists in the SDK for agent-to-agent / +// skill invocation, which this connector does not offer. Restricting to the +// channel issuer keeps the activity `From` trustworthy (the channel sets it), +// so allow_from and the serviceURL binding rest on a signed identity rather than +// caller-supplied body fields. Restore an AAD path here if this bot is ever used +// as a skill target. type inboundValidator struct { - appID string - tenantID string - leeway time.Duration + appID string + leeway time.Duration - bfKeys jwt.Keyfunc // Bot Framework JWKS - aadKeys jwt.Keyfunc // AAD tenant JWKS (nil when no tenant configured) + bfKeys jwt.Keyfunc // Bot Framework JWKS } -// newInboundValidator wires JWKS-backed keyfuncs with rotation caching. The AAD -// keyfunc is only created when a tenant is configured. +// newInboundValidator wires the Bot Framework JWKS keyfunc with rotation caching. func newInboundValidator(cfg config) (*inboundValidator, error) { bf, err := keyfunc.NewDefaultCtx(context.Background(), []string{jwksBotFrameworkURL}) if err != nil { return nil, fmt.Errorf("teams: bot framework JWKS: %w", err) } - v := &inboundValidator{ - appID: cfg.appID, - tenantID: cfg.tenantID, - leeway: defaultLeeway, - bfKeys: bf.Keyfunc, - } - if cfg.tenantID != "" { - aad, err := keyfunc.NewDefaultCtx(context.Background(), []string{aadJWKSURL(cfg.tenantID)}) - if err != nil { - return nil, fmt.Errorf("teams: AAD JWKS: %w", err) - } - v.aadKeys = aad.Keyfunc - } - return v, nil + return &inboundValidator{ + appID: cfg.appID, + leeway: defaultLeeway, + bfKeys: bf.Keyfunc, + }, nil } -// keyFor selects the verification key by the token's issuer, rejecting any -// issuer the connector does not trust before a signature check is attempted. +// keyFor returns the verification key only for the Bot Framework channel issuer, +// rejecting any other issuer before a signature check is attempted. func (v *inboundValidator) keyFor(token *jwt.Token) (any, error) { iss, err := token.Claims.GetIssuer() if err != nil { @@ -76,13 +61,6 @@ func (v *inboundValidator) keyFor(token *jwt.Token) (any, error) { if iss == issuerBotFramework { return v.bfKeys(token) } - if v.aadKeys != nil { - for _, trusted := range aadIssuers(v.tenantID) { - if iss == trusted { - return v.aadKeys(token) - } - } - } return nil, fmt.Errorf("teams: untrusted issuer %q", iss) } diff --git a/platform/teams/auth_inbound_test.go b/platform/teams/auth_inbound_test.go index 183fb371f..5378fe1e1 100644 --- a/platform/teams/auth_inbound_test.go +++ b/platform/teams/auth_inbound_test.go @@ -38,10 +38,9 @@ func (s *testSigner) sign(t *testing.T, claims jwt.MapClaims) string { func validatorWith(s *testSigner) *inboundValidator { return &inboundValidator{ - appID: "app-123", - leeway: defaultLeeway, - bfKeys: s.keyfunc, - aadKeys: s.keyfunc, + appID: "app-123", + leeway: defaultLeeway, + bfKeys: s.keyfunc, } } @@ -115,24 +114,20 @@ func TestValidate_RejectsNonRS256(t *testing.T) { } } -func TestValidate_AcceptsAADIssuerWhenTenantSet(t *testing.T) { +func TestValidate_RejectsAADIssuer(t *testing.T) { s := newTestSigner(t) v := validatorWith(s) - v.tenantID = "tenant-xyz" - c := baseClaims() - c["iss"] = "https://login.microsoftonline.com/tenant-xyz/v2.0" - if _, err := v.validate(s.sign(t, c)); err != nil { - t.Fatalf("expected AAD-issued token to pass when tenant matches, got %v", err) - } -} - -func TestValidate_RejectsAADIssuerWrongTenant(t *testing.T) { - s := newTestSigner(t) - v := validatorWith(s) - v.tenantID = "tenant-xyz" - c := baseClaims() - c["iss"] = "https://login.microsoftonline.com/other-tenant/v2.0" - if _, err := v.validate(s.sign(t, c)); err == nil { - t.Fatal("expected rejection for AAD token from a different tenant") + // AAD-issued tokens (skill / agent-to-agent path) are not accepted by this + // messaging connector — only the Bot Framework channel issuer is trusted, + // even when the signature would otherwise verify. + for _, iss := range []string{ + "https://login.microsoftonline.com/tenant-xyz/v2.0", + "https://sts.windows.net/tenant-xyz/", + } { + c := baseClaims() + c["iss"] = iss + if _, err := v.validate(s.sign(t, c)); err == nil { + t.Fatalf("expected rejection for AAD issuer %q", iss) + } } } diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 253691316..4aeb323ce 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -21,15 +21,23 @@ func init() { core.RegisterPlatform("teams", New) } +// maxConcurrentDispatch caps in-flight agent turns spawned from the webhook. The +// handler acks fast and processes on a goroutine (Bot Framework retry avoidance), +// so without a cap a flood of authenticated activities would spawn unbounded +// concurrent turns. At capacity the webhook sheds with 503 and the Bot Connector +// retries — mirroring the M365 Agents SDK's bounded background queue. +const maxConcurrentDispatch = 16 + // Platform is the cc-connect Teams connector. type Platform struct { cfg config handler core.MessageHandler - validator *inboundValidator - engaged *engagement - conn sender - server *http.Server + validator *inboundValidator + engaged *engagement + conn sender + server *http.Server + dispatchSem chan struct{} // bounds concurrent async dispatch goroutines } // sender abstracts the Bot Connector calls for testability. @@ -55,8 +63,9 @@ func New(opts map[string]any) (core.Platform, error) { } core.CheckAllowFrom("teams", cfg.allowFrom) return &Platform{ - cfg: cfg, - engaged: newEngagement(engagementPath(cfg.dataDir, cfg.project)), + cfg: cfg, + engaged: newEngagement(engagementPath(cfg.dataDir, cfg.project)), + dispatchSem: make(chan struct{}, maxConcurrentDispatch), }, nil } diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index 6ca02276a..ae576cb1d 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -43,10 +43,21 @@ func (p *Platform) handleActivity(w http.ResponseWriter, r *http.Request) { return } - // Ack first, process the turn asynchronously. The card-only connector never - // returns Invoke/ExpectReplies responses, so nothing needs a synchronous body. - w.WriteHeader(http.StatusAccepted) - go p.dispatch(claims, body) + // Ack first, process the turn asynchronously (the card-only connector never + // returns Invoke/ExpectReplies responses, so nothing needs a synchronous body). + // A bounded semaphore caps concurrent turns; at capacity we shed with 503 so + // the Bot Connector retries rather than letting turns spawn unbounded. + select { + case p.dispatchSem <- struct{}{}: + go func() { + defer func() { <-p.dispatchSem }() + p.dispatch(claims, body) + }() + w.WriteHeader(http.StatusAccepted) + default: + slog.Warn("teams: dispatch pool saturated; shedding activity") + http.Error(w, "busy", http.StatusServiceUnavailable) + } } // dispatch parses an activity, enforces serviceURL binding + authorization + diff --git a/platform/teams/webhook_test.go b/platform/teams/webhook_test.go index 5896a79c8..5e18a2dab 100644 --- a/platform/teams/webhook_test.go +++ b/platform/teams/webhook_test.go @@ -9,7 +9,11 @@ import ( ) func testPlatform(s *testSigner) *Platform { - return &Platform{cfg: config{appID: "app-123"}, validator: validatorWith(s)} + return &Platform{ + cfg: config{appID: "app-123"}, + validator: validatorWith(s), + dispatchSem: make(chan struct{}, maxConcurrentDispatch), + } } func TestHandleActivity_ValidTokenAccepted(t *testing.T) { @@ -28,6 +32,25 @@ func TestHandleActivity_ValidTokenAccepted(t *testing.T) { } } +func TestHandleActivity_ShedsWhenSaturated(t *testing.T) { + s := newTestSigner(t) + p := testPlatform(s) + // Saturate the dispatch pool so the next activity has no slot. + for i := 0; i < maxConcurrentDispatch; i++ { + p.dispatchSem <- struct{}{} + } + + req := httptest.NewRequest(http.MethodPost, "/api/messages", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer "+s.sign(t, baseClaims())) + rec := httptest.NewRecorder() + + p.handleActivity(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503 when the dispatch pool is saturated", rec.Code) + } +} + func TestHandleActivity_MissingAuthRejected(t *testing.T) { s := newTestSigner(t) p := testPlatform(s) From b7f3e271c786169ae46b138bf3d1a7af7ab1cc76 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Mon, 6 Jul 2026 16:29:36 +0200 Subject: [PATCH 05/34] feat(teams): configurable card_loading_text (default suggested, empty = none) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the placeholder-card label a config option instead of a hardcoded constant. Empty (the default) renders a label-less card — no built-in fallback string. config.example suggests "💭 Thinking…". Drops the defaultCardWorkingText constant and the loadingCard empty-string fallback. Co-Authored-By: Claude Fable 5 --- config.example.toml | 1 + docs/teams.md | 1 + platform/teams/card.go | 8 +++++--- platform/teams/card_test.go | 17 +++++++++++------ platform/teams/config.go | 8 ++++++-- platform/teams/config_test.go | 18 ++++++++++++++++++ platform/teams/streaming_card.go | 2 +- 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/config.example.toml b/config.example.toml index 14e58db50..f39bf9d99 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1203,6 +1203,7 @@ app_secret = "your-feishu-app-secret" # allow_from = "*" # "*"/empty = all users in the tenant; or comma-separated AAD object IDs / "*"/留空=租户内所有人,或逗号分隔的 AAD 用户 ID # session_scope = "thread" # "thread" (default) | "channel" | "user" # card_update_interval_ms = 1500 # streaming card edit throttle (ms; Teams limits edits ~1/s) +# card_loading_text = "💭 Thinking…" # label on the placeholder card while the agent works; empty = no label # service_url_allowlist = "" # optional defense-in-depth: comma-separated Bot Connector hosts the # # bot may reply to; empty = any JWT-validated host. e.g. public cloud: # # "smba.trafficmanager.net" (see docs/teams.md for gov-cloud hosts) diff --git a/docs/teams.md b/docs/teams.md index 527a8ee77..46ecd491e 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -86,6 +86,7 @@ message — Teams does not allow @mentioning a bot there. | `allow_from` | no | `""` | Comma-separated AAD object IDs allowed to use the bot; `*` or empty = all users in the tenant | | `session_scope` | no | `thread` | `thread` (one session per reply thread), `channel` (one shared session across the channel), `user` (one session per user within a thread) | | `card_update_interval_ms` | no | `1500` | Streaming-card edit throttle in ms; Teams rate-limits edits to ~1/s | +| `card_loading_text` | no | `""` | Label on the placeholder card shown while the agent works (e.g. `💭 Thinking…`); empty renders a label-less card | | `service_url_allowlist` | no | `""` | Comma-separated hosts the bot may send replies to. Empty = any JWT-validated host (default). Set it to pin the bot to your cloud's Bot Connector host(s) as defense-in-depth. See "serviceURL allowlist" below | ## serviceURL allowlist diff --git a/platform/teams/card.go b/platform/teams/card.go index de51837d5..a8d85b851 100644 --- a/platform/teams/card.go +++ b/platform/teams/card.go @@ -6,11 +6,13 @@ package teams const adaptiveCardContentType = "application/vnd.microsoft.card.adaptive" -// loadingCard is the "working" card shown immediately while the agent thinks. -// The text is rendered italic (markdown) and subtle/small — a quiet, grayed cue. +// loadingCard is the placeholder card shown immediately while the agent thinks, +// carrying the configured card_loading_text. The text is rendered italic +// (markdown) and subtle/small — a quiet, grayed cue. An empty text yields a +// label-less (empty-body) card rather than an empty TextBlock. func loadingCard(text string) map[string]any { if text == "" { - text = defaultCardWorkingText + return adaptiveCard(nil) } return adaptiveCard([]map[string]any{ {"type": "TextBlock", "text": "_" + text + "_", "wrap": true, "isSubtle": true, "size": "Small"}, diff --git a/platform/teams/card_test.go b/platform/teams/card_test.go index 96465a485..661bfc69b 100644 --- a/platform/teams/card_test.go +++ b/platform/teams/card_test.go @@ -14,13 +14,13 @@ func firstTextBlock(card map[string]any) map[string]any { } func TestLoadingCard(t *testing.T) { - c := loadingCard("💭 Working…") + c := loadingCard("💭 Thinking…") if c["type"] != "AdaptiveCard" || c["version"] != "1.5" { t.Fatalf("not an AdaptiveCard 1.5: %v", c) } tb := firstTextBlock(c) txt, _ := tb["text"].(string) - if tb["type"] != "TextBlock" || !strings.Contains(txt, "💭 Working…") { + if tb["type"] != "TextBlock" || !strings.Contains(txt, "💭 Thinking…") { t.Errorf("loading text block = %v", tb) } // grayed + small + italic (markdown) @@ -32,10 +32,15 @@ func TestLoadingCard(t *testing.T) { } } -func TestLoadingCard_EmptyFallsBackToDefault(t *testing.T) { - tb := firstTextBlock(loadingCard("")) - if txt, _ := tb["text"].(string); !strings.Contains(txt, defaultCardWorkingText) { - t.Errorf("empty text -> %v, want it to contain default %q", tb["text"], defaultCardWorkingText) +func TestLoadingCard_EmptyRendersNoBody(t *testing.T) { + // Empty card_loading_text yields a label-less placeholder (no TextBlock), + // not a TextBlock containing an empty string. + c := loadingCard("") + if c["type"] != "AdaptiveCard" { + t.Fatalf("not an AdaptiveCard: %v", c) + } + if tb := firstTextBlock(c); tb != nil { + t.Errorf("empty text should render no body block, got %v", tb) } } diff --git a/platform/teams/config.go b/platform/teams/config.go index dcdb0fd05..78b107471 100644 --- a/platform/teams/config.go +++ b/platform/teams/config.go @@ -12,8 +12,7 @@ import ( const ( defaultWebhookPort = "3978" defaultWebhookPath = "/api/messages" - defaultCardWorkingText = "💭 Working…" // "working" card label shown while the agent thinks - defaultCardUpdateIntervalMS = 1500 // card edit throttle; Teams rate-limits edits ~1/s + defaultCardUpdateIntervalMS = 1500 // card edit throttle; Teams rate-limits edits ~1/s ) // config holds the resolved Teams platform settings parsed from the config.toml @@ -42,6 +41,10 @@ type config struct { // Bot Framework / M365 Agents SDK, which trust the authenticated inbound host. serviceURLAllowlist []string + // cardLoadingText is the label on the placeholder card shown while the agent + // thinks. Empty (default) renders a label-less card — no built-in default. + cardLoadingText string + cardUpdateIntervalMS int // card edit throttle (ms); smaller = finer chunks (floor ~1s) // dataDir and project are injected by cc-connect (cc_data_dir / cc_project) @@ -62,6 +65,7 @@ func parseConfig(opts map[string]any) (config, error) { allowFrom: stringOpt(opts, "allow_from"), sessionScope: normalizeSessionScope(opts["session_scope"]), serviceURLAllowlist: splitCSV(stringOpt(opts, "service_url_allowlist")), + cardLoadingText: strings.TrimSpace(stringOpt(opts, "card_loading_text")), dataDir: stringOpt(opts, "cc_data_dir"), project: stringOpt(opts, "cc_project"), } diff --git a/platform/teams/config_test.go b/platform/teams/config_test.go index a7130301b..3c6d05957 100644 --- a/platform/teams/config_test.go +++ b/platform/teams/config_test.go @@ -163,6 +163,24 @@ func TestServiceURLAllowed(t *testing.T) { } } +func TestParseConfig_CardLoadingText(t *testing.T) { + // absent -> empty (label-less card, no built-in default) + c, _ := parseConfig(validOpts()) + if c.cardLoadingText != "" { + t.Errorf("absent card_loading_text -> %q, want empty", c.cardLoadingText) + } + // set -> used verbatim (trimmed) + opts := validOpts() + opts["card_loading_text"] = " 💭 Thinking… " + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.cardLoadingText != "💭 Thinking…" { + t.Errorf("card_loading_text = %q, want trimmed value", c.cardLoadingText) + } +} + func TestNew_RegistersAsPlatform(t *testing.T) { p, err := New(validOpts()) if err != nil { diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go index 8fccb7d50..c82873fba 100644 --- a/platform/teams/streaming_card.go +++ b/platform/teams/streaming_card.go @@ -36,7 +36,7 @@ func (p *Platform) streamInterval() time.Duration { // createCardStream posts the loading Adaptive Card immediately and returns a // handle that edits it in place. func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core.StreamingCard, error) { - id, err := p.conn.send(ctx, rc, aiCardActivity(rc, loadingCard(""))) + id, err := p.conn.send(ctx, rc, aiCardActivity(rc, loadingCard(p.cfg.cardLoadingText))) if err != nil { return nil, err } From dbce1fb1f033674868f819e6ea7bbbc580673ea6 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:33:13 +0200 Subject: [PATCH 06/34] feat(teams): parse inbound 1:1 attachments off the activity Add inboundAttachment parsing (file-download-info + image classification) and an isPersonal() gate on the activity, so a 1:1 file/image can be surfaced to the connector. Distinct from the outbound card attachment type. Co-Authored-By: Claude Opus 4.8 --- platform/teams/activity.go | 54 ++++++++++++++++++ platform/teams/activity_test.go | 98 ++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/platform/teams/activity.go b/platform/teams/activity.go index 1135bcb93..a8a3c3806 100644 --- a/platform/teams/activity.go +++ b/platform/teams/activity.go @@ -16,9 +16,63 @@ type activity struct { Recipient channelAccount `json:"recipient"` Conversation conversationAccount `json:"conversation"` Entities []entity `json:"entities"` + Attachments []inboundAttachment `json:"attachments"` Value json.RawMessage `json:"value"` } +// fileDownloadInfoContentType is the attachment contentType Teams uses for a file +// a user sends the bot in a 1:1 chat. Its `content` is a fileDownloadInfo. +const fileDownloadInfoContentType = "application/vnd.microsoft.teams.file.download.info" + +// inboundAttachment is an attachment on an *inbound* activity. It is distinct +// from the outbound `attachment` card type (connector.go): inbound payloads are +// kept as raw JSON so typed contents like FileDownloadInfo can be decoded lazily. +type inboundAttachment struct { + ContentType string `json:"contentType"` + ContentURL string `json:"contentUrl"` + Content json.RawMessage `json:"content"` + Name string `json:"name"` +} + +// fileDownloadInfo is the `content` of a FileDownloadInfo attachment. downloadUrl +// is a pre-authenticated link (no bearer token required); fileType is the file +// extension without a dot (e.g. "docx"). +type fileDownloadInfo struct { + DownloadURL string `json:"downloadUrl"` + FileType string `json:"fileType"` + UniqueID string `json:"uniqueId"` +} + +// isFileDownload reports whether the attachment is a 1:1 FileDownloadInfo. +func (att inboundAttachment) isFileDownload() bool { + return strings.EqualFold(att.ContentType, fileDownloadInfoContentType) +} + +// downloadInfo decodes a FileDownloadInfo attachment's content. The bool is false +// when the attachment is not a file download or its content is unparseable. +func (att inboundAttachment) downloadInfo() (fileDownloadInfo, bool) { + if !att.isFileDownload() || len(att.Content) == 0 { + return fileDownloadInfo{}, false + } + var info fileDownloadInfo + if err := json.Unmarshal(att.Content, &info); err != nil { + return fileDownloadInfo{}, false + } + return info, true +} + +// isImage reports whether the attachment is an inline image (contentType image/*). +func (att inboundAttachment) isImage() bool { + return strings.HasPrefix(strings.ToLower(att.ContentType), "image/") +} + +// isPersonal reports whether the activity is a 1:1 (personal) chat. Attachment +// handling is gated to this context: channel/group files require Microsoft Graph +// and are out of scope. +func (a *activity) isPersonal() bool { + return strings.EqualFold(a.Conversation.ConversationType, "personal") +} + type channelAccount struct { ID string `json:"id"` AADObjectID string `json:"aadObjectId"` diff --git a/platform/teams/activity_test.go b/platform/teams/activity_test.go index 89f8aa587..9c9e382b1 100644 --- a/platform/teams/activity_test.go +++ b/platform/teams/activity_test.go @@ -1,6 +1,9 @@ package teams -import "testing" +import ( + "encoding/json" + "testing" +) func TestCleanText_StripsMention(t *testing.T) { a := &activity{ @@ -80,6 +83,99 @@ func TestCardAction(t *testing.T) { } } +func TestActivityAttachments_ParseFileDownloadInfo(t *testing.T) { + body := []byte(`{ + "type":"message", + "conversation":{"conversationType":"personal"}, + "attachments":[{ + "contentType":"application/vnd.microsoft.teams.file.download.info", + "name":"report.docx", + "content":{"downloadUrl":"https://onedrive.example/pre-authed","fileType":"docx","uniqueId":"u-1"} + }] + }`) + a, err := parseActivity(body) + if err != nil { + t.Fatalf("parseActivity: %v", err) + } + if len(a.Attachments) != 1 { + t.Fatalf("attachments = %d, want 1", len(a.Attachments)) + } + att := a.Attachments[0] + if !att.isFileDownload() { + t.Error("expected isFileDownload true") + } + if att.Name != "report.docx" { + t.Errorf("name = %q", att.Name) + } + info, ok := att.downloadInfo() + if !ok { + t.Fatal("downloadInfo not parsed") + } + if info.DownloadURL != "https://onedrive.example/pre-authed" || info.FileType != "docx" { + t.Errorf("downloadInfo = %+v", info) + } +} + +func TestActivityAttachments_ParseImage(t *testing.T) { + body := []byte(`{ + "type":"message", + "attachments":[{"contentType":"image/png","contentUrl":"https://smba.example/v3/attachments/x"}] + }`) + a, err := parseActivity(body) + if err != nil { + t.Fatalf("parseActivity: %v", err) + } + att := a.Attachments[0] + if !att.isImage() { + t.Error("expected isImage true for image/png") + } + if att.isFileDownload() { + t.Error("image must not classify as file download") + } + if att.ContentURL != "https://smba.example/v3/attachments/x" { + t.Errorf("contentUrl = %q", att.ContentURL) + } + if _, ok := att.downloadInfo(); ok { + t.Error("downloadInfo must be false for a non-file attachment") + } +} + +func TestActivityAttachments_NoneWhenAbsent(t *testing.T) { + a, err := parseActivity([]byte(`{"type":"message","text":"hi"}`)) + if err != nil { + t.Fatalf("parseActivity: %v", err) + } + if len(a.Attachments) != 0 { + t.Errorf("attachments = %d, want 0", len(a.Attachments)) + } +} + +func TestActivityAttachments_CorruptFileContentSkips(t *testing.T) { + att := inboundAttachment{ + ContentType: fileDownloadInfoContentType, + Content: json.RawMessage(`not-json`), + } + if _, ok := att.downloadInfo(); ok { + t.Error("corrupt file content should not parse") + } +} + +func TestActivity_IsPersonal(t *testing.T) { + cases := map[string]bool{ + "personal": true, + "Personal": true, + "channel": false, + "groupChat": false, + "": false, + } + for convType, want := range cases { + a := &activity{Conversation: conversationAccount{ConversationType: convType}} + if got := a.isPersonal(); got != want { + t.Errorf("isPersonal(%q) = %v, want %v", convType, got, want) + } + } +} + func TestSessionKey_Scopes(t *testing.T) { a := &activity{Conversation: conversationAccount{ID: "conv-9"}, From: channelAccount{ID: "user-7"}} cases := map[string]string{ From ce954faa8521f37c6ce62020ca122b2931ea67ac Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:34:51 +0200 Subject: [PATCH 07/34] feat(teams): bounded attachment download on the connector Add connector.fetch (size-capped GET, optional bearer) + fetchOutcome so inbound attachments can be downloaded best-effort; add max_attachment_bytes config (default 20 MiB). The bearer token is attached only on request, never to a pre-authed file downloadUrl. Co-Authored-By: Claude Opus 4.8 --- platform/teams/config.go | 12 +++- platform/teams/config_test.go | 27 ++++++++ platform/teams/connector.go | 47 ++++++++++++++ platform/teams/connector_test.go | 102 +++++++++++++++++++++++++++++++ platform/teams/reply_test.go | 20 ++++++ platform/teams/teams.go | 1 + 6 files changed, 208 insertions(+), 1 deletion(-) diff --git a/platform/teams/config.go b/platform/teams/config.go index 78b107471..1392f5330 100644 --- a/platform/teams/config.go +++ b/platform/teams/config.go @@ -12,7 +12,8 @@ import ( const ( defaultWebhookPort = "3978" defaultWebhookPath = "/api/messages" - defaultCardUpdateIntervalMS = 1500 // card edit throttle; Teams rate-limits edits ~1/s + defaultCardUpdateIntervalMS = 1500 // card edit throttle; Teams rate-limits edits ~1/s + defaultMaxAttachmentBytes = 20 << 20 // 20 MiB cap per inbound attachment download ) // config holds the resolved Teams platform settings parsed from the config.toml @@ -47,6 +48,11 @@ type config struct { cardUpdateIntervalMS int // card edit throttle (ms); smaller = finer chunks (floor ~1s) + // maxAttachmentBytes caps each inbound 1:1 attachment download. A payload + // larger than this is skipped (with a user notice) rather than truncated or + // buffered unbounded. Defaults to defaultMaxAttachmentBytes. + maxAttachmentBytes int64 + // dataDir and project are injected by cc-connect (cc_data_dir / cc_project) // and locate the on-disk engagement store. Empty => engagement stays // in-memory only (e.g. tests / standalone construction). @@ -73,6 +79,10 @@ func parseConfig(opts map[string]any) (config, error) { if c.cardUpdateIntervalMS <= 0 { c.cardUpdateIntervalMS = defaultCardUpdateIntervalMS } + c.maxAttachmentBytes = int64(intOpt(opts, "max_attachment_bytes", defaultMaxAttachmentBytes)) + if c.maxAttachmentBytes <= 0 { + c.maxAttachmentBytes = defaultMaxAttachmentBytes + } if c.appID == "" { return config{}, fmt.Errorf("teams: app_id is required") diff --git a/platform/teams/config_test.go b/platform/teams/config_test.go index 3c6d05957..743c0b432 100644 --- a/platform/teams/config_test.go +++ b/platform/teams/config_test.go @@ -181,6 +181,33 @@ func TestParseConfig_CardLoadingText(t *testing.T) { } } +func TestParseConfig_MaxAttachmentBytes(t *testing.T) { + // absent -> default + c, _ := parseConfig(validOpts()) + if c.maxAttachmentBytes != defaultMaxAttachmentBytes { + t.Errorf("absent max_attachment_bytes -> %d, want default %d", c.maxAttachmentBytes, defaultMaxAttachmentBytes) + } + // override + non-positive fallback + cases := map[any]int64{ + int64(5 << 20): 5 << 20, + 1048576: 1048576, + 0: defaultMaxAttachmentBytes, + -1: defaultMaxAttachmentBytes, + "nope": defaultMaxAttachmentBytes, + } + for in, want := range cases { + opts := validOpts() + opts["max_attachment_bytes"] = in + c, err := parseConfig(opts) + if err != nil { + t.Fatalf("max_attachment_bytes %v: unexpected error: %v", in, err) + } + if c.maxAttachmentBytes != want { + t.Errorf("max_attachment_bytes %v -> %d, want %d", in, c.maxAttachmentBytes, want) + } + } +} + func TestNew_RegistersAsPlatform(t *testing.T) { p, err := New(validOpts()) if err != nil { diff --git a/platform/teams/connector.go b/platform/teams/connector.go index e9e0326e2..41c7cb926 100644 --- a/platform/teams/connector.go +++ b/platform/teams/connector.go @@ -128,6 +128,53 @@ func (c *connector) sendTo(ctx context.Context, url string, a outboundActivity) return rr.ID, nil } +// fetchOutcome classifies an attachment download so the caller can tell a usable +// payload apart from an oversize or failed one (the latter two drive a user notice). +type fetchOutcome int + +const ( + fetchOK fetchOutcome = iota // download succeeded within the size cap + fetchOversize // payload exceeded maxBytes; skipped + fetchFailed // request/transport/status error; skipped +) + +// fetch GETs rawURL and returns its bytes bounded by maxBytes. A bearer token is +// attached only when withToken is true (file downloadUrls are pre-authenticated +// and must NOT carry the token; images on the Bot Connector host require it). A +// payload larger than maxBytes is reported as fetchOversize rather than truncated, +// and any transport/status error as fetchFailed — neither is fatal to the turn. +func (c *connector) fetch(ctx context.Context, rawURL string, withToken bool, maxBytes int64) ([]byte, fetchOutcome) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fetchFailed + } + if withToken { + token, err := c.tokens.token(ctx) + if err != nil { + return nil, fetchFailed + } + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fetchFailed + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fetchFailed + } + // Read one byte past the cap so a payload exactly at the limit still succeeds + // while anything larger is detected as oversize. + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) + if err != nil { + return nil, fetchFailed + } + if int64(len(data)) > maxBytes { + return nil, fetchOversize + } + return data, fetchOK +} + func (c *connector) do(ctx context.Context, method, url string, a outboundActivity) ([]byte, error) { payload, err := json.Marshal(a) if err != nil { diff --git a/platform/teams/connector_test.go b/platform/teams/connector_test.go index 6178f0963..ff21d393c 100644 --- a/platform/teams/connector_test.go +++ b/platform/teams/connector_test.go @@ -168,6 +168,108 @@ func TestNewMessageActivity_OmitsAttachmentsWhenNone(t *testing.T) { } } +func TestConnectorFetch_SmallPayloadSucceeds(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("hello-bytes")) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + data, outcome := c.fetch(context.Background(), srv.URL, false, 1024) + if outcome != fetchOK { + t.Fatalf("outcome = %v, want fetchOK", outcome) + } + if string(data) != "hello-bytes" { + t.Errorf("data = %q", data) + } +} + +func TestConnectorFetch_OversizeRejected(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(make([]byte, 100)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + data, outcome := c.fetch(context.Background(), srv.URL, false, 10) + if outcome != fetchOversize { + t.Fatalf("outcome = %v, want fetchOversize", outcome) + } + if data != nil { + t.Errorf("oversize download should return no data, got %d bytes", len(data)) + } +} + +func TestConnectorFetch_ExactCapSucceeds(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(make([]byte, 10)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + data, outcome := c.fetch(context.Background(), srv.URL, false, 10) + if outcome != fetchOK || len(data) != 10 { + t.Fatalf("payload exactly at the cap should succeed: outcome=%v len=%d", outcome, len(data)) + } +} + +func TestConnectorFetch_HTTPErrorReturnsFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + if _, outcome := c.fetch(context.Background(), srv.URL, false, 1024); outcome != fetchFailed { + t.Fatalf("outcome = %v, want fetchFailed on 404", outcome) + } +} + +func TestConnectorFetch_TransportErrorReturnsFailed(t *testing.T) { + c := newConnector(&staticTokens{value: "t"}) + // Unroutable/closed endpoint -> transport error, not a panic. + if _, outcome := c.fetch(context.Background(), "http://127.0.0.1:0/nope", false, 1024); outcome != fetchFailed { + t.Fatalf("outcome = %v, want fetchFailed on transport error", outcome) + } +} + +func TestConnectorFetch_AttachesBearerOnlyWhenRequested(t *testing.T) { + var withAuth, withoutAuth string + srvWith := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + withAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte("x")) + })) + defer srvWith.Close() + srvWithout := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + withoutAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte("x")) + })) + defer srvWithout.Close() + + c := newConnector(&staticTokens{value: "tok-img"}) + c.fetch(context.Background(), srvWith.URL, true, 1024) + c.fetch(context.Background(), srvWithout.URL, false, 1024) + + if withAuth != "Bearer tok-img" { + t.Errorf("withToken=true should send bearer, got %q", withAuth) + } + if withoutAuth != "" { + t.Errorf("withToken=false must not send the bot token to the URL, got %q", withoutAuth) + } +} + +func TestConnectorFetch_TokenErrorReturnsFailed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("x")) + })) + defer srv.Close() + + c := newConnector(failTokens{}) + if _, outcome := c.fetch(context.Background(), srv.URL, true, 1024); outcome != fetchFailed { + t.Fatalf("outcome = %v, want fetchFailed when the token cannot be acquired", outcome) + } +} + func TestTokenSource_CachesAndReuses(t *testing.T) { var hits int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/platform/teams/reply_test.go b/platform/teams/reply_test.go index aa799851c..fb26c789a 100644 --- a/platform/teams/reply_test.go +++ b/platform/teams/reply_test.go @@ -14,6 +14,26 @@ type fakeSender struct { updatedIDs []string replied []outboundActivity repliedToID []string + + // fetch behavior: keyed by requested URL, falling back to fetchDefault. + fetchByURL map[string]fetchResult + fetchDefault fetchResult + fetchedURLs []string + fetchwithToken []bool +} + +type fetchResult struct { + data []byte + outcome fetchOutcome +} + +func (f *fakeSender) fetch(_ context.Context, url string, withToken bool, _ int64) ([]byte, fetchOutcome) { + f.fetchedURLs = append(f.fetchedURLs, url) + f.fetchwithToken = append(f.fetchwithToken, withToken) + if r, ok := f.fetchByURL[url]; ok { + return r.data, r.outcome + } + return f.fetchDefault.data, f.fetchDefault.outcome } func (f *fakeSender) send(_ context.Context, rc replyContext, a outboundActivity) (string, error) { diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 4aeb323ce..427c95f65 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -45,6 +45,7 @@ type sender interface { send(ctx context.Context, rc replyContext, a outboundActivity) (string, error) replyTo(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error update(ctx context.Context, rc replyContext, activityID string, a outboundActivity) error + fetch(ctx context.Context, url string, withToken bool, maxBytes int64) ([]byte, fetchOutcome) } // Optional-interface assertions: the engine type-switches on these to drive From 588a55e33173daea6ab7902fcd779a63827c74d2 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:37:30 +0200 Subject: [PATCH 08/34] feat(teams): deliver 1:1 inbound files and images to the agent In a personal chat, download file/image attachments and populate Message.Images/Files so the engine hands them to the agent; a file-only message (empty text) now dispatches. Images fetch the bot token only when the contentUrl shares the JWT-bound serviceURL host; data: URIs decode inline. Channel/group attachments stay ignored. Co-Authored-By: Claude Opus 4.8 --- platform/teams/session_test.go | 124 +++++++++++++++++++++++++++ platform/teams/webhook.go | 151 +++++++++++++++++++++++++++++++-- 2 files changed, 266 insertions(+), 9 deletions(-) diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index 11917c238..99baa4e19 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -337,6 +337,130 @@ func TestDispatch_ServiceURLAllowlist(t *testing.T) { } } +// fileDL builds a FileDownloadInfo attachment with the given name + downloadUrl. +func fileDL(name, downloadURL string) inboundAttachment { + content, _ := json.Marshal(fileDownloadInfo{DownloadURL: downloadURL, FileType: "docx"}) + return inboundAttachment{ContentType: fileDownloadInfoContentType, Name: name, Content: content} +} + +// personalActivity builds a 1:1 message with optional attachments. +func personalActivity(text string, atts ...inboundAttachment) []byte { + return mustJSON(activity{ + Type: "message", + ID: "act-1", + Text: text, + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "user-1", Name: "User"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "dm-1", ConversationType: "personal"}, + Attachments: atts, + }) +} + +func personalPlatform(fetch fetchResult) (*Platform, *[]*core.Message, *fakeSender) { + p := teamsPlatform("user") + fs := &fakeSender{fetchDefault: fetch} + p.conn = fs + h, got := collector() + p.handler = h + return p, got, fs +} + +func TestDispatch_PersonalFileAttachmentDelivered(t *testing.T) { + p, got, fs := personalPlatform(fetchResult{data: []byte("DOCX-BYTES"), outcome: fetchOK}) + p.dispatch(nil, personalActivity("", fileDL("report.docx", "https://files.example/dl"))) + + if len(*got) != 1 { + t.Fatalf("file-only 1:1 message must still dispatch, got %d", len(*got)) + } + m := (*got)[0] + if len(m.Files) != 1 || len(m.Images) != 0 { + t.Fatalf("want 1 file, 0 images; got files=%d images=%d", len(m.Files), len(m.Images)) + } + f := m.Files[0] + if f.FileName != "report.docx" || string(f.Data) != "DOCX-BYTES" || f.MimeType == "" { + t.Errorf("file attachment = %+v", f) + } + // A pre-authed file downloadUrl must NOT carry the bot token. + if len(fs.fetchwithToken) != 1 || fs.fetchwithToken[0] { + t.Errorf("file download must not attach the bot token, withToken=%v", fs.fetchwithToken) + } +} + +func TestDispatch_PersonalImageDelivered(t *testing.T) { + p, got, fs := personalPlatform(fetchResult{data: []byte("PNG"), outcome: fetchOK}) + p.dispatch(nil, personalActivity("", inboundAttachment{ContentType: "image/png", ContentURL: "https://smba.example/v3/attachments/x"})) + + if len(*got) != 1 { + t.Fatalf("image 1:1 message must dispatch, got %d", len(*got)) + } + m := (*got)[0] + if len(m.Images) != 1 || len(m.Files) != 0 { + t.Fatalf("want 1 image, 0 files; got images=%d files=%d", len(m.Images), len(m.Files)) + } + if m.Images[0].MimeType != "image/png" || string(m.Images[0].Data) != "PNG" { + t.Errorf("image attachment = %+v", m.Images[0]) + } + // contentUrl host == serviceURL host -> the bot token is attached. + if len(fs.fetchwithToken) != 1 || !fs.fetchwithToken[0] { + t.Errorf("same-host image should carry the bot token, withToken=%v", fs.fetchwithToken) + } +} + +func TestDispatch_ForeignHostImageOmitsToken(t *testing.T) { + p, _, fs := personalPlatform(fetchResult{data: []byte("PNG"), outcome: fetchOK}) + p.dispatch(nil, personalActivity("", inboundAttachment{ContentType: "image/jpeg", ContentURL: "https://cdn.foreign.example/img.jpg"})) + if len(fs.fetchwithToken) != 1 || fs.fetchwithToken[0] { + t.Errorf("a foreign-host image URL must NOT receive the bot token, withToken=%v", fs.fetchwithToken) + } +} + +func TestDispatch_TextAndAttachmentSameTurn(t *testing.T) { + p, got, _ := personalPlatform(fetchResult{data: []byte("X"), outcome: fetchOK}) + p.dispatch(nil, personalActivity("review this", fileDL("a.docx", "https://files.example/dl"))) + if len(*got) != 1 { + t.Fatalf("got %d messages", len(*got)) + } + m := (*got)[0] + if m.Content != "review this" || len(m.Files) != 1 { + t.Errorf("text+attachment should share one turn: content=%q files=%d", m.Content, len(m.Files)) + } +} + +func TestDispatch_ChannelAttachmentIgnored(t *testing.T) { + p := teamsPlatform("thread") + fs := &fakeSender{fetchDefault: fetchResult{data: []byte("X"), outcome: fetchOK}} + p.conn = fs + h, got := collector() + p.handler = h + + // Channel message that @mentions the bot (so it passes the engagement gate) + // and carries a file attachment. The attachment must be ignored (no Graph), + // and the text path proceeds unchanged. + a := activity{ + Type: "message", + ID: "act-1", + Text: "bot look", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "conv-A", ConversationType: "channel"}, + Entities: []entity{{Type: "mention", Text: "bot", Mentioned: channelAccount{ID: "bot-1"}}}, + Attachments: []inboundAttachment{fileDL("secret.docx", "https://files.example/dl")}, + } + p.dispatch(nil, mustJSON(a)) + + if len(*got) != 1 { + t.Fatalf("channel text should still dispatch, got %d", len(*got)) + } + if len((*got)[0].Files) != 0 || len((*got)[0].Images) != 0 { + t.Errorf("channel attachment must be ignored, got files=%d images=%d", len((*got)[0].Files), len((*got)[0].Images)) + } + if len(fs.fetchedURLs) != 0 { + t.Errorf("channel attachment must not trigger a download, fetched=%v", fs.fetchedURLs) + } +} + // TestSessionKey_ScopeVariants covers AE8: the three scopes produce distinct // keys, and channel scope collapses sibling threads to the channel root. func TestSessionKey_ScopeVariants(t *testing.T) { diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index ae576cb1d..5d2505de4 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -1,9 +1,14 @@ package teams import ( + "context" + "encoding/base64" "io" "log/slog" + "mime" "net/http" + "net/url" + "path/filepath" "strings" "github.com/chenhg5/cc-connect/core" @@ -91,8 +96,11 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { action := a.cardAction() isCardAction := action != "" content := a.cleanText() - if content == "" && !isCardAction { - return // empty message with no card action + // Inbound media is 1:1 only; a channel/group attachment is ignored (R5). A + // message carrying attachments still dispatches even with empty text (R3). + hasMedia := a.isPersonal() && len(a.Attachments) > 0 + if content == "" && !isCardAction && !hasMedia { + return // empty message with no card action and no attachment } // Authorize before touching engagement so an unauthorized @mention cannot // flip a conversation into the engaged set. @@ -105,6 +113,13 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { } sessionKey := p.sessionKey(a) + rc := replyContext{ + serviceURL: a.ServiceURL, + conversationID: a.Conversation.ID, + activityID: a.ID, + botAccount: a.Recipient, + userAccount: a.From, + } msg := &core.Message{ SessionKey: sessionKey, Platform: "teams", @@ -113,23 +128,141 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { UserID: userID(a), UserName: a.From.Name, ChatName: a.Conversation.Name, - ReplyCtx: replyContext{ - serviceURL: a.ServiceURL, - conversationID: a.Conversation.ID, - activityID: a.ID, - botAccount: a.Recipient, - userAccount: a.From, - }, + ReplyCtx: rc, } if isCardAction { msg.Content = action msg.IsPermissionResponse = true } else { msg.Content = content + if hasMedia { + images, files, failed := p.downloadInboundMedia(a) + msg.Images = images + msg.Files = files + if failed > 0 { + slog.Warn("teams: some inbound attachments were skipped", "count", failed) + } + } } p.handler(p, msg) } +// downloadInboundMedia downloads a 1:1 activity's file and image attachments, +// returning them as core attachments plus a count of downloads that were too +// large or failed (surfaced to the user by the caller). It runs on the async +// dispatch goroutine, so the blocking HTTP here never delays the webhook ack. +func (p *Platform) downloadInboundMedia(a *activity) (images []core.ImageAttachment, files []core.FileAttachment, failed int) { + if p.conn == nil { + return nil, nil, 0 + } + ctx := context.Background() + max := p.cfg.maxAttachmentBytes + if max <= 0 { + max = defaultMaxAttachmentBytes + } + for _, att := range a.Attachments { + switch { + case att.isFileDownload(): + info, ok := att.downloadInfo() + if !ok || info.DownloadURL == "" { + continue // malformed file attachment; nothing to fetch + } + // downloadUrl is pre-authenticated: fetch WITHOUT the bot token. + data, outcome := p.conn.fetch(ctx, info.DownloadURL, false, max) + if outcome != fetchOK { + failed++ + continue + } + files = append(files, core.FileAttachment{ + MimeType: mimeForFile(att.Name, info.FileType), + Data: data, + FileName: att.Name, + }) + case att.isImage(): + data, outcome := p.fetchImage(ctx, att, a.ServiceURL, max) + if outcome != fetchOK { + failed++ + continue + } + images = append(images, core.ImageAttachment{ + MimeType: att.ContentType, + Data: data, + FileName: att.Name, + }) + } + } + return images, files, failed +} + +// fetchImage retrieves an inline image attachment. A data: URI is decoded in +// place; otherwise the contentUrl is fetched, attaching the bot bearer token +// only when the URL is on the same host as the JWT-validated serviceURL (the Bot +// Connector attachment endpoint). This keeps the token from ever reaching a +// foreign host embedded in a forged contentUrl. +func (p *Platform) fetchImage(ctx context.Context, att inboundAttachment, serviceURL string, maxBytes int64) ([]byte, fetchOutcome) { + if strings.HasPrefix(att.ContentURL, "data:") { + if data, ok := decodeDataURI(att.ContentURL, maxBytes); ok { + return data, fetchOK + } + return nil, fetchFailed + } + if att.ContentURL == "" { + return nil, fetchFailed + } + return p.conn.fetch(ctx, att.ContentURL, sameHost(att.ContentURL, serviceURL), maxBytes) +} + +// decodeDataURI decodes a base64 data: URI, enforcing the same size cap as a +// network download. Only base64 payloads are supported (Teams inline images). +func decodeDataURI(uri string, maxBytes int64) ([]byte, bool) { + comma := strings.IndexByte(uri, ',') + if comma < 0 { + return nil, false + } + meta, payload := uri[:comma], uri[comma+1:] + if !strings.Contains(meta, "base64") { + return nil, false + } + data, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + return nil, false + } + if int64(len(data)) > maxBytes { + return nil, false + } + return data, true +} + +// sameHost reports whether two URLs share a host (case-insensitive). Used to gate +// whether the bot bearer token may accompany an image download. +func sameHost(a, b string) bool { + ua, err := url.Parse(a) + if err != nil || ua.Host == "" { + return false + } + ub, err := url.Parse(b) + if err != nil || ub.Host == "" { + return false + } + return strings.EqualFold(ua.Host, ub.Host) +} + +// mimeForFile derives a MIME type from a downloaded file's name or the +// FileDownloadInfo fileType extension, falling back to a generic binary type. +func mimeForFile(name, fileType string) string { + if ext := filepath.Ext(name); ext != "" { + if mt := mime.TypeByExtension(ext); mt != "" { + return mt + } + } + if fileType != "" { + if mt := mime.TypeByExtension("." + strings.TrimPrefix(fileType, ".")); mt != "" { + return mt + } + } + return "application/octet-stream" +} + // serviceURLClaimMatches reports whether the token's serviceurl claim matches // the activity serviceUrl. When the claim is absent (e.g. the Bot Framework // Emulator), it does not block — a replayed real channel token always carries From 06803e9ce4e71a5fceb7d8e2cef6053fed0a24c1 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:38:38 +0200 Subject: [PATCH 09/34] feat(teams): notify the user when an inbound attachment can't be read When a 1:1 attachment download fails or exceeds max_attachment_bytes, reply a brief notice instead of dropping it silently; the turn still proceeds with whatever text and other attachments succeeded. Co-Authored-By: Claude Opus 4.8 --- platform/teams/session_test.go | 38 ++++++++++++++++++++++++++++++++++ platform/teams/webhook.go | 10 +++++++++ 2 files changed, 48 insertions(+) diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index 99baa4e19..2af3a8fb9 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/chenhg5/cc-connect/core" @@ -461,6 +462,43 @@ func TestDispatch_ChannelAttachmentIgnored(t *testing.T) { } } +func TestDispatch_NoticeOnFailedDownload(t *testing.T) { + p, got, fs := personalPlatform(fetchResult{outcome: fetchFailed}) + p.dispatch(nil, personalActivity("review this", fileDL("a.docx", "https://files.example/dl"))) + + if len(*got) != 1 { + t.Fatalf("turn should still dispatch its text, got %d", len(*got)) + } + if (*got)[0].Content != "review this" { + t.Errorf("text should survive a failed attachment, content=%q", (*got)[0].Content) + } + if len((*got)[0].Files) != 0 { + t.Errorf("failed download must not attach a file, got %d", len((*got)[0].Files)) + } + if len(fs.replied) != 1 || !strings.Contains(fs.replied[0].Text, "couldn't read") { + t.Errorf("a failed download should send a user notice, replied=%+v", fs.replied) + } +} + +func TestDispatch_NoticeOnOversizeDownload(t *testing.T) { + p, _, fs := personalPlatform(fetchResult{outcome: fetchOversize}) + p.dispatch(nil, personalActivity("", fileDL("big.docx", "https://files.example/dl"))) + if len(fs.replied) != 1 { + t.Fatalf("an oversize download should send a user notice, replied=%d", len(fs.replied)) + } +} + +func TestDispatch_NoNoticeOnSuccess(t *testing.T) { + p, got, fs := personalPlatform(fetchResult{data: []byte("OK"), outcome: fetchOK}) + p.dispatch(nil, personalActivity("", fileDL("a.docx", "https://files.example/dl"))) + if len((*got)[0].Files) != 1 { + t.Fatalf("successful download should attach the file") + } + if len(fs.replied) != 0 { + t.Errorf("a successful download must not send a notice, replied=%+v", fs.replied) + } +} + // TestSessionKey_ScopeVariants covers AE8: the three scopes produce distinct // keys, and channel scope collapses sibling threads to the channel root. func TestSessionKey_ScopeVariants(t *testing.T) { diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index 5d2505de4..dafe7e1b8 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -18,6 +18,11 @@ import ( // maxBodyBytes caps the activity payload read from the connector. const maxBodyBytes = 1 << 20 // 1 MiB +// attachmentFailureNotice is sent to the user when an inbound attachment can't be +// delivered (too large or its download failed), instead of dropping it silently. +// A user-facing i18n key is a possible follow-up; kept a literal for now. +const attachmentFailureNotice = "⚠️ I couldn't read one of your attachments — it may be too large or unavailable." + // handleActivity is the Bot Connector webhook entry point. It authenticates the // request and reads the body synchronously, then acks 202 and runs the agent turn // on a background goroutine. Bot Framework expects a fast ack (~15s) and retries @@ -140,7 +145,12 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { msg.Images = images msg.Files = files if failed > 0 { + // Tell the user rather than silently dropping the attachment; the + // turn still proceeds with whatever text/attachments succeeded. slog.Warn("teams: some inbound attachments were skipped", "count", failed) + if err := p.Reply(context.Background(), rc, attachmentFailureNotice); err != nil { + slog.Warn("teams: failed to send attachment notice", "error", err) + } } } } From 14dcae808e809c65fc2860b426c8e6d42661cc8f Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:39:38 +0200 Subject: [PATCH 10/34] docs(teams): document 1:1 inbound media, supportsFiles manifest, size cap Co-Authored-By: Claude Opus 4.8 --- config.example.toml | 2 ++ docs/teams.md | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/config.example.toml b/config.example.toml index f39bf9d99..95da2173d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1207,6 +1207,8 @@ app_secret = "your-feishu-app-secret" # service_url_allowlist = "" # optional defense-in-depth: comma-separated Bot Connector hosts the # # bot may reply to; empty = any JWT-validated host. e.g. public cloud: # # "smba.trafficmanager.net" (see docs/teams.md for gov-cloud hosts) +# max_attachment_bytes = 20971520 # cap per inbound 1:1 file/image download (default 20 MiB); larger = skipped with a notice +# # 1:1 file receive also needs supportsFiles:true in the Teams app manifest (see docs/teams.md) # MAX messenger (uncomment to enable / 取消注释以启用) # 1. Create a bot via @MasterBot in MAX, get the access token diff --git a/docs/teams.md b/docs/teams.md index 46ecd491e..e4ee0a7b3 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -88,6 +88,7 @@ message — Teams does not allow @mentioning a bot there. | `card_update_interval_ms` | no | `1500` | Streaming-card edit throttle in ms; Teams rate-limits edits to ~1/s | | `card_loading_text` | no | `""` | Label on the placeholder card shown while the agent works (e.g. `💭 Thinking…`); empty renders a label-less card | | `service_url_allowlist` | no | `""` | Comma-separated hosts the bot may send replies to. Empty = any JWT-validated host (default). Set it to pin the bot to your cloud's Bot Connector host(s) as defense-in-depth. See "serviceURL allowlist" below | +| `max_attachment_bytes` | no | `20971520` (20 MiB) | Cap per inbound 1:1 file/image download. A larger attachment is skipped with a notice rather than buffered. See "Receiving files and images" below | ## serviceURL allowlist @@ -112,6 +113,36 @@ reference (verify against Azure docs — this list can change): Leave it empty unless you have a reason to pin — a too-narrow list silently drops legitimate traffic. +## Receiving files and images + +In a **1:1 (personal) chat**, a file or image the user attaches to the bot is +downloaded by the connector and handed to the agent (a file becomes a saved file +the agent can read; an image is passed as an image attachment). Text and an +attachment sent together arrive on the same turn. + +**Manifest prerequisite:** for the bot to receive files in 1:1, the Teams app +manifest must declare: + +```json +"bots": [ + { "botId": "", "supportsFiles": true, "scopes": ["personal"] } +] +``` + +Without `supportsFiles: true`, Teams does not deliver file attachments to the bot +— no connector setting substitutes for it. + +**Size limit:** each download is capped by `max_attachment_bytes` (default +20 MiB). An oversized attachment, or one whose download fails, is skipped and the +user gets a brief notice; the turn still proceeds with any text and other +attachments. + +**Not supported:** +- **Channel / group attachments.** Files posted in a channel or group chat live + in SharePoint and require Microsoft Graph + tenant admin consent; attachments + outside a 1:1 chat are ignored, not partially handled. +- **Outbound media** (the bot *sending* files/images) is not implemented. + ## Connection type Webhook (Bot Framework) — **a public HTTPS URL is required**. This is inherent @@ -124,6 +155,7 @@ which POSTs to your endpoint. toggle, and the native `streamType` 1:1 animation are deferred to follow-ups. - Permission prompts render as plain text with numbered options (reply with a number or `yes`); interactive Adaptive Card buttons are deferred. -- No inbound images/files/audio. +- Inbound files and images are supported in **1:1 chats only** (see "Receiving + files and images"); channel/group attachments and inbound audio are not. - No cron/timer → Teams proactive messages (a conversation-reference store is needed to send without an incoming activity). From c19d7df6e8ecebc79e063bbbb739325e66d285cd Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Tue, 7 Jul 2026 09:47:06 +0200 Subject: [PATCH 11/34] fix(review): notify on malformed attachments; gate dispatch on handled media Code-review follow-ups (both P3): - A file-download attachment with an empty/unparseable downloadUrl is now counted as a failed download, so the user gets a notice instead of a silently dropped turn. - Dispatch now gates on a processable attachment (file or image), so a 1:1 message whose only attachment is an unhandled type no longer forces an empty-content turn. Co-Authored-By: Claude Opus 4.8 --- platform/teams/activity.go | 14 ++++++++++++++ platform/teams/session_test.go | 33 +++++++++++++++++++++++++++++++++ platform/teams/webhook.go | 10 +++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/platform/teams/activity.go b/platform/teams/activity.go index a8a3c3806..68a4c708d 100644 --- a/platform/teams/activity.go +++ b/platform/teams/activity.go @@ -73,6 +73,20 @@ func (a *activity) isPersonal() bool { return strings.EqualFold(a.Conversation.ConversationType, "personal") } +// hasProcessableAttachment reports whether the activity carries at least one +// attachment this connector actually handles (a file download or an inline +// image). Keying the dispatch gate on this — rather than a raw attachment count — +// avoids dispatching an empty-content turn for a message whose only attachment is +// an unhandled type (e.g. a link preview) with no text. +func (a *activity) hasProcessableAttachment() bool { + for _, att := range a.Attachments { + if att.isFileDownload() || att.isImage() { + return true + } + } + return false +} + type channelAccount struct { ID string `json:"id"` AADObjectID string `json:"aadObjectId"` diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index 2af3a8fb9..332a53a4e 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -488,6 +488,39 @@ func TestDispatch_NoticeOnOversizeDownload(t *testing.T) { } } +func TestDispatch_MalformedFileAttachmentNotifies(t *testing.T) { + // A file-download attachment with an empty downloadUrl can't be fetched. It + // must be counted as a failure (notice sent), not silently dropped into an + // empty turn. + p, got, fs := personalPlatform(fetchResult{outcome: fetchOK}) + bad := inboundAttachment{ContentType: fileDownloadInfoContentType, Name: "x.docx", Content: []byte(`{"downloadUrl":""}`)} + p.dispatch(nil, personalActivity("", bad)) + + if len(fs.fetchedURLs) != 0 { + t.Errorf("an empty downloadUrl must not be fetched, got %v", fs.fetchedURLs) + } + if len(fs.replied) != 1 { + t.Fatalf("a malformed file attachment should send a notice, replied=%d", len(fs.replied)) + } + if len(*got) != 1 || len((*got)[0].Files) != 0 { + t.Errorf("no file should be attached for a malformed attachment") + } +} + +func TestDispatch_UnhandledOnlyAttachmentNotDispatched(t *testing.T) { + // A 1:1 message with empty text whose only attachment is neither a file + // download nor an image (e.g. a link preview) must NOT dispatch an empty turn. + p, got, fs := personalPlatform(fetchResult{outcome: fetchOK}) + p.dispatch(nil, personalActivity("", inboundAttachment{ContentType: "application/vnd.microsoft.card.thumbnail"})) + + if len(*got) != 0 { + t.Fatalf("empty-text message with only an unhandled attachment must be dropped, got %d", len(*got)) + } + if len(fs.fetchedURLs) != 0 { + t.Errorf("no download should be attempted for an unhandled attachment, got %v", fs.fetchedURLs) + } +} + func TestDispatch_NoNoticeOnSuccess(t *testing.T) { p, got, fs := personalPlatform(fetchResult{data: []byte("OK"), outcome: fetchOK}) p.dispatch(nil, personalActivity("", fileDL("a.docx", "https://files.example/dl"))) diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index dafe7e1b8..f5254aec0 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -102,8 +102,9 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { isCardAction := action != "" content := a.cleanText() // Inbound media is 1:1 only; a channel/group attachment is ignored (R5). A - // message carrying attachments still dispatches even with empty text (R3). - hasMedia := a.isPersonal() && len(a.Attachments) > 0 + // message carrying a handled attachment still dispatches even with empty text + // (R3); an unhandled-only attachment must not force an empty-content turn. + hasMedia := a.isPersonal() && a.hasProcessableAttachment() if content == "" && !isCardAction && !hasMedia { return // empty message with no card action and no attachment } @@ -175,7 +176,10 @@ func (p *Platform) downloadInboundMedia(a *activity) (images []core.ImageAttachm case att.isFileDownload(): info, ok := att.downloadInfo() if !ok || info.DownloadURL == "" { - continue // malformed file attachment; nothing to fetch + // Malformed file attachment: count as failed so the user is + // notified rather than left with a silently dropped turn. + failed++ + continue } // downloadUrl is pre-authenticated: fetch WITHOUT the bot token. data, outcome := p.conn.fetch(ctx, info.DownloadURL, false, max) From f7db4cc4e173522dbf014afb53412c3e2b761acc Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:03:39 +0200 Subject: [PATCH 12/34] docs(teams): fix doc consistency for AI-label timing and allow_from default - teams.md: native AI label is present from the first card render, not only at finalize - config.example.toml: allow_from default is unset/empty (behaves like "*"), not "*" - CLAUDE.md: add no_teams to the build-tag list Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- config.example.toml | 4 ++-- docs/teams.md | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 022df7bba..4fafc3d26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ go build -tags 'no_discord no_dingtalk no_qq no_qqbot no_line' ./cmd/cc-connect Available tags: `no_acp`, `no_claudecode`, `no_codex`, `no_cursor`, `no_gemini`, `no_iflow`, `no_opencode`, `no_qoder`, `no_feishu`, `no_telegram`, `no_discord`, `no_slack`, `no_dingtalk`, `no_wecom`, `no_weixin`, `no_qq`, `no_qqbot`, -`no_line`, `no_weibo`, `no_matrix`, `no_webex`. +`no_line`, `no_weibo`, `no_matrix`, `no_webex`, `no_teams`. ## Pre-Commit Checklist diff --git a/config.example.toml b/config.example.toml index 95da2173d..b5ba78c3b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1188,8 +1188,8 @@ app_secret = "your-feishu-app-secret" # Replies stream as an Adaptive Card. See docs/teams.md for the full setup. / 完整配置见 docs/teams.md。 # Single-tenant only (tenant_id required) — multi-tenant bot creation is deprecated by Azure. # 仅支持单租户(必须填 tenant_id)——Azure 已弃用多租户机器人创建。 -# allow_from = "*" (default) permits anyone in your tenant; set an AAD-object-ID allowlist -# to restrict to specific users. / allow_from = "*"(默认)允许本租户任何人;填 AAD 用户 ID 可限定到特定用户。 +# Omitting allow_from (or "*") permits anyone in your tenant; set an AAD-object-ID allowlist +# to restrict to specific users. / 留空 allow_from(或填 "*")允许本租户任何人;填 AAD 用户 ID 可限定到特定用户。 # [[projects.platforms]] # type = "teams" diff --git a/docs/teams.md b/docs/teams.md index e4ee0a7b3..948459455 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -12,8 +12,9 @@ requires a publicly reachable URL. - The connector validates each request's JWT, then forwards the message to the cc-connect engine. - Replies are sent back through the Bot Connector REST API and stream as an - **Adaptive Card**: a "working" card is posted immediately, edited in place as - the answer grows, and finalized with the native "AI generated" label. The card + **Adaptive Card**: a "working" card is posted immediately, then edited in place + as the answer grows. Every card — the working card included — carries the native + "AI generated" label, so it is present from the first render. The card renders uniformly in channels, group chats, and 1:1 (unlike the native `streamType` protocol, which is one-on-one only). From 186bacd41c4133179518a0cd32ba7b80d163bb23 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:26:12 +0200 Subject: [PATCH 13/34] feat(teams): add inline media fields to outbound attachment schema Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/connector.go | 11 +++++++--- platform/teams/connector_test.go | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/platform/teams/connector.go b/platform/teams/connector.go index 41c7cb926..15071d1f1 100644 --- a/platform/teams/connector.go +++ b/platform/teams/connector.go @@ -29,11 +29,16 @@ type outboundActivity struct { Entities []map[string]any `json:"entities,omitempty"` } -// attachment carries a card payload; for Adaptive Cards ContentType is -// "application/vnd.microsoft.card.adaptive" and Content is the card object. +// attachment carries either a card payload or an inline media payload. For +// Adaptive Cards ContentType is "application/vnd.microsoft.card.adaptive" and +// Content is the card object. For an inline image ContentType is the image mime +// (e.g. "image/png"), ContentUrl is a "data:;base64,<...>" URI, and Name +// is the filename — the Bot Framework inline-attachment shape. type attachment struct { ContentType string `json:"contentType"` - Content any `json:"content"` + Content any `json:"content,omitempty"` + ContentUrl string `json:"contentUrl,omitempty"` + Name string `json:"name,omitempty"` } // newMessageActivity builds a message activity with the conversation-reference diff --git a/platform/teams/connector_test.go b/platform/teams/connector_test.go index ff21d393c..16b6d2633 100644 --- a/platform/teams/connector_test.go +++ b/platform/teams/connector_test.go @@ -293,3 +293,40 @@ func TestTokenSource_CachesAndReuses(t *testing.T) { t.Errorf("token endpoint hit %d times, want 1 (cached)", got) } } + +func TestAttachmentMarshal_MediaEmitsContentUrlName(t *testing.T) { + b, err := json.Marshal(attachment{ContentType: "image/png", ContentUrl: "data:image/png;base64,AAAA", Name: "chart.png"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m["contentUrl"] != "data:image/png;base64,AAAA" || m["name"] != "chart.png" { + t.Errorf("media attachment missing contentUrl/name: %s", b) + } + if _, ok := m["content"]; ok { + t.Errorf("media attachment should omit content key: %s", b) + } +} + +func TestAttachmentMarshal_CardOmitsMediaKeys(t *testing.T) { + b, err := json.Marshal(attachment{ContentType: adaptiveCardContentType, Content: map[string]any{"type": "AdaptiveCard"}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := m["content"]; !ok { + t.Errorf("card attachment must keep content key: %s", b) + } + if _, ok := m["contentUrl"]; ok { + t.Errorf("card attachment should omit contentUrl: %s", b) + } + if _, ok := m["name"]; ok { + t.Errorf("card attachment should omit name: %s", b) + } +} From d1eb8e891af60afba93ee2804adb48a2f4ac8a86 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:27:02 +0200 Subject: [PATCH 14/34] feat(teams): build inline image message activity from ImageAttachment Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/media.go | 27 +++++++++++++++++++++ platform/teams/media_test.go | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 platform/teams/media.go create mode 100644 platform/teams/media_test.go diff --git a/platform/teams/media.go b/platform/teams/media.go new file mode 100644 index 000000000..2a237862b --- /dev/null +++ b/platform/teams/media.go @@ -0,0 +1,27 @@ +package teams + +import ( + "encoding/base64" + + "github.com/chenhg5/cc-connect/core" +) + +// imageActivity builds a message activity carrying one image as an inline Bot +// Framework attachment: contentType is the image mime and contentUrl is a +// "data:;base64,<...>" URI (the documented inline-image mechanism, which +// keeps the connector free of any file-hosting surface). Missing mime/filename +// default to PNG, matching other platform senders. +func imageActivity(rc replyContext, img core.ImageAttachment) outboundActivity { + mime := img.MimeType + if mime == "" { + mime = "image/png" + } + name := img.FileName + if name == "" { + name = "image.png" + } + dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img.Data) + a := newActivity(rc, "message") + a.Attachments = []attachment{{ContentType: mime, ContentUrl: dataURI, Name: name}} + return a +} diff --git a/platform/teams/media_test.go b/platform/teams/media_test.go new file mode 100644 index 000000000..8cebf38f7 --- /dev/null +++ b/platform/teams/media_test.go @@ -0,0 +1,46 @@ +package teams + +import ( + "encoding/base64" + "strings" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestImageActivity_BuildsDataURIAttachment(t *testing.T) { + rc := replyContext{ + conversationID: "c1", + botAccount: channelAccount{ID: "bot"}, + userAccount: channelAccount{ID: "user"}, + } + data := []byte{0x89, 0x50, 0x4e, 0x47} + a := imageActivity(rc, core.ImageAttachment{MimeType: "image/png", Data: data, FileName: "chart.png"}) + + if len(a.Attachments) != 1 { + t.Fatalf("want 1 attachment, got %d", len(a.Attachments)) + } + att := a.Attachments[0] + want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) + if att.ContentUrl != want { + t.Errorf("contentUrl = %q, want %q", att.ContentUrl, want) + } + if att.ContentType != "image/png" || att.Name != "chart.png" { + t.Errorf("contentType/name = %q/%q", att.ContentType, att.Name) + } + // Conversation-reference envelope carried through. + if a.Type != "message" || a.From == nil || a.From.ID != "bot" || a.Recipient == nil || a.Recipient.ID != "user" { + t.Errorf("envelope missing: %+v", a) + } +} + +func TestImageActivity_DefaultsMimeAndName(t *testing.T) { + a := imageActivity(replyContext{}, core.ImageAttachment{Data: []byte{1, 2, 3}}) + att := a.Attachments[0] + if att.ContentType != "image/png" || att.Name != "image.png" { + t.Errorf("defaults not applied: contentType=%q name=%q", att.ContentType, att.Name) + } + if !strings.HasPrefix(att.ContentUrl, "data:image/png;base64,") { + t.Errorf("contentUrl prefix wrong: %q", att.ContentUrl) + } +} From 1040ee1de511767254e5d51d7c4115c0a91eb218 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:29:41 +0200 Subject: [PATCH 15/34] feat(teams): implement ImageSender for outbound inline images Bot now sends images back to Teams as inline base64 attachments, threaded to the originating activity. Oversize images degrade to a text notice instead of failing the turn. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/media_test.go | 61 ++++++++++++++++++++++++++++++++++++ platform/teams/teams.go | 32 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/platform/teams/media_test.go b/platform/teams/media_test.go index 8cebf38f7..b070c9eb1 100644 --- a/platform/teams/media_test.go +++ b/platform/teams/media_test.go @@ -1,6 +1,7 @@ package teams import ( + "context" "encoding/base64" "strings" "testing" @@ -44,3 +45,63 @@ func TestImageActivity_DefaultsMimeAndName(t *testing.T) { t.Errorf("contentUrl prefix wrong: %q", att.ContentUrl) } } + +func TestSendImage_ThreadsToOriginatingActivity(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} + + if err := p.SendImage(context.Background(), rc, core.ImageAttachment{Data: []byte{1, 2, 3}, MimeType: "image/png"}); err != nil { + t.Fatalf("SendImage: %v", err) + } + if len(fs.replied) != 1 || fs.repliedToID[0] != "a1" { + t.Fatalf("want threaded reply to a1, got %+v / %v", fs.replied, fs.repliedToID) + } + if len(fs.replied[0].Attachments) != 1 || fs.replied[0].Attachments[0].ContentUrl == "" { + t.Errorf("image attachment missing: %+v", fs.replied[0]) + } +} + +func TestSendImage_UnthreadedUsesSend(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1"} // no activityID + + if err := p.SendImage(context.Background(), rc, core.ImageAttachment{Data: []byte{1, 2, 3}}); err != nil { + t.Fatalf("SendImage: %v", err) + } + if len(fs.replied) != 0 { + t.Fatalf("should not thread without activityID: %+v", fs.replied) + } + if len(fs.last.Attachments) != 1 || fs.last.Attachments[0].ContentUrl == "" { + t.Errorf("image attachment missing on send: %+v", fs.last) + } +} + +func TestSendImage_OversizeSendsNoticeNotImage(t *testing.T) { + fs := &fakeSender{} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} + big := make([]byte, maxOutboundImageBytes+1) + + if err := p.SendImage(context.Background(), rc, core.ImageAttachment{Data: big, MimeType: "image/png"}); err != nil { + t.Fatalf("SendImage: %v", err) + } + if len(fs.replied) != 1 { + t.Fatalf("want one notice reply, got %+v", fs.replied) + } + got := fs.replied[0] + if got.Text != oversizeImageNotice { + t.Errorf("notice text = %q, want %q", got.Text, oversizeImageNotice) + } + if len(got.Attachments) != 0 { + t.Errorf("oversize should carry no attachment: %+v", got.Attachments) + } +} + +func TestSendImage_InvalidReplyCtx(t *testing.T) { + p := &Platform{conn: &fakeSender{}} + if err := p.SendImage(context.Background(), "not-a-reply-ctx", core.ImageAttachment{Data: []byte{1}}); err == nil { + t.Fatal("want error for invalid reply context") + } +} diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 427c95f65..9e0350db3 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -54,6 +54,7 @@ var ( _ core.ReplyContextReconstructor = (*Platform)(nil) _ core.FormattingInstructionProvider = (*Platform)(nil) _ core.StreamingCardPlatform = (*Platform)(nil) + _ core.ImageSender = (*Platform)(nil) ) // New builds a Teams platform from the config.toml options table. @@ -131,6 +132,37 @@ func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error return err } +// maxOutboundImageBytes caps a single outbound image. Images ride inline as a +// base64 data: URI, which inflates the payload ~33% and is bounded by the Bot +// Connector's activity size limit; a conservative cap keeps a large image from +// being rejected wholesale. Kept a constant (not config) until a deployment +// needs to tune it. Oversize images degrade to a text notice, not a failed turn. +const maxOutboundImageBytes = 1 << 20 // 1 MiB + +// oversizeImageNotice is sent in place of an image too large to deliver inline. +// A user-facing i18n key is a possible follow-up; kept a literal for now, +// mirroring attachmentFailureNotice on the inbound side. +const oversizeImageNotice = "⚠️ I couldn't send an image — it's too large to deliver in Teams." + +// SendImage delivers an image as an inline attachment, threaded to the +// originating activity like a text Reply. An image larger than the cap degrades +// to a text notice so the turn is not lost. +func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error { + rc, ok := replyCtx.(replyContext) + if !ok { + return fmt.Errorf("teams: invalid reply context %T", replyCtx) + } + if len(img.Data) > maxOutboundImageBytes { + return p.Reply(ctx, rc, oversizeImageNotice) + } + a := imageActivity(rc, img) + if rc.activityID == "" { + _, err := p.conn.send(ctx, rc, a) + return err + } + return p.conn.replyTo(ctx, rc, rc.activityID, a) +} + // ReconstructReplyCtx rebuilds a reply context from a session key. Only the // conversation id is recoverable from the key; the per-activity serviceURL is // not encoded, so proactive sends (cron→Teams) need a conversation-reference From 22df9689538a5badcc7113d5758903cd0816e4dc Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:31:11 +0200 Subject: [PATCH 16/34] docs(teams): document outbound image send Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/teams.md | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4deb8ad1..68ab6582b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`agent_session_idle_timeout_mins`**: new per-project config option that closes an idle live agent process after a clean turn while preserving the cc-connect session and saved agent session ID. The next message starts a new agent process and resumes the same conversation. Set to `0` or leave unset to disable (#1338). - **Reasonix agent**: new agent adapter for Reasonix multi-model coding agent, bridging via HTTP serve API (POST /submit, SSE /events, POST /approve). Supports default/yolo/plan permission modes, SSE auto-reconnect with backoff, and thinking accumulator. (#1281) - **cloud_web platform**: 新增 self-hosted IM Gateway 作为 first-class platform 接入 (CWIP v1 协议,支持 websocket / long_poll / gateway 3 种 transport,完整 inbound/outbound + capability negotiation + graceful degradation)。 详见 docs/cloud-web.md + #1282。 -- **Microsoft Teams platform**: new Bot Framework connector — inbound webhook with JWT + serviceURL validation, outbound Bot Connector REST with AAD auth, streaming Adaptive Card replies (working card → answer, native AI label), mention-to-engage with per-thread follow, and `session_scope` (thread/channel/user). See docs/teams.md. +- **Microsoft Teams platform**: new Bot Framework connector — inbound webhook with JWT + serviceURL validation, outbound Bot Connector REST with AAD auth, streaming Adaptive Card replies (working card → answer, native AI label), inbound 1:1 files/images, outbound inline image send (size-capped), mention-to-engage with per-thread follow, and `session_scope` (thread/channel/user). See docs/teams.md. ## Unreleased diff --git a/docs/teams.md b/docs/teams.md index 948459455..d96741fb2 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -138,11 +138,22 @@ Without `supportsFiles: true`, Teams does not deliver file attachments to the bo user gets a brief notice; the turn still proceeds with any text and other attachments. +## Sending images + +When the agent produces an image (or a user runs `cc-connect send --image`), the +bot sends it back **inline** as a base64 data-URI attachment, threaded to the +originating message. It renders in 1:1 chats, group chats, and channels. Images +above ~1 MiB are skipped with a brief text notice rather than failing the turn, +because inline delivery is bounded by the Bot Connector's activity size limit. +Sending **files** (non-image) is not supported — Teams requires a separate file +consent / SharePoint flow. + **Not supported:** - **Channel / group attachments.** Files posted in a channel or group chat live in SharePoint and require Microsoft Graph + tenant admin consent; attachments outside a 1:1 chat are ignored, not partially handled. -- **Outbound media** (the bot *sending* files/images) is not implemented. +- **Outbound files** (the bot *sending* non-image files) are not implemented. + Outbound **images** are supported (see "Sending images"). ## Connection type From ffb8cb4ae40789e6f84cdcb16fe35cdd56b65fa0 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 19:38:14 +0200 Subject: [PATCH 17/34] fix(review): rename ContentUrl->ContentURL; add SendImage error-propagation test Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/connector.go | 4 ++-- platform/teams/connector_test.go | 2 +- platform/teams/media.go | 2 +- platform/teams/media_test.go | 36 ++++++++++++++++++++++++++------ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/platform/teams/connector.go b/platform/teams/connector.go index 15071d1f1..f4f4c47f9 100644 --- a/platform/teams/connector.go +++ b/platform/teams/connector.go @@ -32,12 +32,12 @@ type outboundActivity struct { // attachment carries either a card payload or an inline media payload. For // Adaptive Cards ContentType is "application/vnd.microsoft.card.adaptive" and // Content is the card object. For an inline image ContentType is the image mime -// (e.g. "image/png"), ContentUrl is a "data:;base64,<...>" URI, and Name +// (e.g. "image/png"), ContentURL is a "data:;base64,<...>" URI, and Name // is the filename — the Bot Framework inline-attachment shape. type attachment struct { ContentType string `json:"contentType"` Content any `json:"content,omitempty"` - ContentUrl string `json:"contentUrl,omitempty"` + ContentURL string `json:"contentUrl,omitempty"` Name string `json:"name,omitempty"` } diff --git a/platform/teams/connector_test.go b/platform/teams/connector_test.go index 16b6d2633..47837e4aa 100644 --- a/platform/teams/connector_test.go +++ b/platform/teams/connector_test.go @@ -295,7 +295,7 @@ func TestTokenSource_CachesAndReuses(t *testing.T) { } func TestAttachmentMarshal_MediaEmitsContentUrlName(t *testing.T) { - b, err := json.Marshal(attachment{ContentType: "image/png", ContentUrl: "data:image/png;base64,AAAA", Name: "chart.png"}) + b, err := json.Marshal(attachment{ContentType: "image/png", ContentURL: "data:image/png;base64,AAAA", Name: "chart.png"}) if err != nil { t.Fatalf("marshal: %v", err) } diff --git a/platform/teams/media.go b/platform/teams/media.go index 2a237862b..518797c72 100644 --- a/platform/teams/media.go +++ b/platform/teams/media.go @@ -22,6 +22,6 @@ func imageActivity(rc replyContext, img core.ImageAttachment) outboundActivity { } dataURI := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(img.Data) a := newActivity(rc, "message") - a.Attachments = []attachment{{ContentType: mime, ContentUrl: dataURI, Name: name}} + a.Attachments = []attachment{{ContentType: mime, ContentURL: dataURI, Name: name}} return a } diff --git a/platform/teams/media_test.go b/platform/teams/media_test.go index b070c9eb1..da153a260 100644 --- a/platform/teams/media_test.go +++ b/platform/teams/media_test.go @@ -3,12 +3,15 @@ package teams import ( "context" "encoding/base64" + "errors" "strings" "testing" "github.com/chenhg5/cc-connect/core" ) +var errBoom = errors.New("boom") + func TestImageActivity_BuildsDataURIAttachment(t *testing.T) { rc := replyContext{ conversationID: "c1", @@ -23,8 +26,8 @@ func TestImageActivity_BuildsDataURIAttachment(t *testing.T) { } att := a.Attachments[0] want := "data:image/png;base64," + base64.StdEncoding.EncodeToString(data) - if att.ContentUrl != want { - t.Errorf("contentUrl = %q, want %q", att.ContentUrl, want) + if att.ContentURL != want { + t.Errorf("contentUrl = %q, want %q", att.ContentURL, want) } if att.ContentType != "image/png" || att.Name != "chart.png" { t.Errorf("contentType/name = %q/%q", att.ContentType, att.Name) @@ -41,8 +44,8 @@ func TestImageActivity_DefaultsMimeAndName(t *testing.T) { if att.ContentType != "image/png" || att.Name != "image.png" { t.Errorf("defaults not applied: contentType=%q name=%q", att.ContentType, att.Name) } - if !strings.HasPrefix(att.ContentUrl, "data:image/png;base64,") { - t.Errorf("contentUrl prefix wrong: %q", att.ContentUrl) + if !strings.HasPrefix(att.ContentURL, "data:image/png;base64,") { + t.Errorf("contentUrl prefix wrong: %q", att.ContentURL) } } @@ -57,7 +60,7 @@ func TestSendImage_ThreadsToOriginatingActivity(t *testing.T) { if len(fs.replied) != 1 || fs.repliedToID[0] != "a1" { t.Fatalf("want threaded reply to a1, got %+v / %v", fs.replied, fs.repliedToID) } - if len(fs.replied[0].Attachments) != 1 || fs.replied[0].Attachments[0].ContentUrl == "" { + if len(fs.replied[0].Attachments) != 1 || fs.replied[0].Attachments[0].ContentURL == "" { t.Errorf("image attachment missing: %+v", fs.replied[0]) } } @@ -73,7 +76,7 @@ func TestSendImage_UnthreadedUsesSend(t *testing.T) { if len(fs.replied) != 0 { t.Fatalf("should not thread without activityID: %+v", fs.replied) } - if len(fs.last.Attachments) != 1 || fs.last.Attachments[0].ContentUrl == "" { + if len(fs.last.Attachments) != 1 || fs.last.Attachments[0].ContentURL == "" { t.Errorf("image attachment missing on send: %+v", fs.last) } } @@ -105,3 +108,24 @@ func TestSendImage_InvalidReplyCtx(t *testing.T) { t.Fatal("want error for invalid reply context") } } + +func TestSendImage_PropagatesConnectorError(t *testing.T) { + img := core.ImageAttachment{Data: []byte{1, 2, 3}} + // Threaded path (activityID set) routes through replyTo; unthreaded through send. + for _, tc := range []struct { + name string + activityID string + }{ + {"threaded", "a1"}, + {"unthreaded", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + fs := &fakeSender{err: errBoom} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: tc.activityID} + if err := p.SendImage(context.Background(), rc, img); err != errBoom { + t.Fatalf("want errBoom propagated, got %v", err) + } + }) + } +} From eb59c3c184688252450a12c02ed7dbcdf7b84e15 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 20:06:02 +0200 Subject: [PATCH 18/34] docs(core): document outbound-media delivery model on ImageSender/FileSender Capture the non-obvious constraint surfaced this session: images/files reach platforms only via the external send socket, never through agent reply Events, and the 0600 daemon-owned socket blocks run_as_user agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/interfaces.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/interfaces.go b/core/interfaces.go index 2d8011028..0f70557d7 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -279,11 +279,19 @@ type AtMentionSender interface { } // ImageSender is an optional interface for platforms that support sending images. +// +// Delivery is external-only: images reach a platform solely via the send API +// (cc-connect send / cron -> APIServer Unix socket -> SendToSessionWithOptions), +// never through agent reply Events — Event carries no image/file field, so an +// agent-generated image does not auto-flow to any platform. The send socket is +// 0600 daemon-owned (api.go), so a project's run_as_user (unprivileged) agent +// cannot open it: media delivery is unavailable under run_as_user, every platform. type ImageSender interface { SendImage(ctx context.Context, replyCtx any, img ImageAttachment) error } // FileSender is an optional interface for platforms that support sending files. +// Same external-only delivery model and run_as_user constraint as ImageSender. type FileSender interface { SendFile(ctx context.Context, replyCtx any, file FileAttachment) error } From 2d9f8f85732994bad0366cabcd10bc6dbea918c7 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 20:08:29 +0200 Subject: [PATCH 19/34] docs(readme): add Teams column to platform feature snapshot Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index acecc862e..6bb970153 100644 --- a/README.md +++ b/README.md @@ -207,17 +207,18 @@ High-level view of what each **built-in platform** can do in cc-connect. † **QQ (NapCat / OneBot)** — unofficial self-hosted bridge; behaviour depends on your NapCat / network setup. -| Capability | Feishu | WPS Xiezuo | DingTalk | Telegram | Slack | Discord | LINE | WeCom | Weibo | **Weixin**
*(personal)* | QQ† | QQ Bot | Matrix | -|------------|:------:|:----------:|:--------:|:--------:|:-----:|:-------:|:----:|:-----:|:-----:|:-------------------------:|:---:|:------:|:------:| -| Text & slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Markdown / cards | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅ | ✅ | ✅ | ⚠️ | -| Streaming / chunked replies | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Images & files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | -| Voice / STT / TTS | ⚠️ | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ❌ | ✅ | ⚠️ | ⚠️ | ❌ | -| Private (DM) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Group / channel | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | +| Capability | Feishu | WPS Xiezuo | DingTalk | Telegram | Slack | Discord | LINE | WeCom | Weibo | **Weixin**
*(personal)* | QQ† | QQ Bot | Matrix | Teams | +|------------|:------:|:----------:|:--------:|:--------:|:-----:|:-------:|:----:|:-----:|:-----:|:-------------------------:|:---:|:------:|:------:|:-----:| +| Text & slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Markdown / cards | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ✅ | +| Streaming / chunked replies | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Images & files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| Voice / STT / TTS | ⚠️ | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ❌ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ | +| Private (DM) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Group / channel | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | > **WeCom:** Webhook mode needs a **public URL**; long-connection / WS style setups often do not. +> **Teams:** needs a **public URL** (Bot Framework webhook). Images & files = ⚠️: inbound files/images in 1:1 chats and outbound **images** only — no outbound files, no channel/group inbound. See [docs/teams.md](docs/teams.md). > **Voice row:** many platforms need `[speech]` / TTS providers enabled in `config.toml`; values are a best-effort summary. > Per-platform setup: [Platform setup guides](#-platform-setup-guides) below. From 73b85db4bf528ad4d74c8f361bf9cbdbdf0e4f9e Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Wed, 8 Jul 2026 20:09:30 +0200 Subject: [PATCH 20/34] Revert "docs(core): document outbound-media delivery model on ImageSender/FileSender" This reverts commit eb59c3c184688252450a12c02ed7dbcdf7b84e15. --- core/interfaces.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/core/interfaces.go b/core/interfaces.go index 0f70557d7..2d8011028 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -279,19 +279,11 @@ type AtMentionSender interface { } // ImageSender is an optional interface for platforms that support sending images. -// -// Delivery is external-only: images reach a platform solely via the send API -// (cc-connect send / cron -> APIServer Unix socket -> SendToSessionWithOptions), -// never through agent reply Events — Event carries no image/file field, so an -// agent-generated image does not auto-flow to any platform. The send socket is -// 0600 daemon-owned (api.go), so a project's run_as_user (unprivileged) agent -// cannot open it: media delivery is unavailable under run_as_user, every platform. type ImageSender interface { SendImage(ctx context.Context, replyCtx any, img ImageAttachment) error } // FileSender is an optional interface for platforms that support sending files. -// Same external-only delivery model and run_as_user constraint as ImageSender. type FileSender interface { SendFile(ctx context.Context, replyCtx any, file FileAttachment) error } From 42e246bd8a53bb2da05c9abe5790fe24c9df0876 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 10:58:25 +0200 Subject: [PATCH 21/34] feat(teams): track active streaming card per conversation Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/streaming_card.go | 13 +++++++-- platform/teams/streaming_card_test.go | 40 +++++++++++++++++++++++++++ platform/teams/teams.go | 36 ++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go index c82873fba..50bc7b417 100644 --- a/platform/teams/streaming_card.go +++ b/platform/teams/streaming_card.go @@ -43,12 +43,15 @@ func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core. if id == "" { return nil, fmt.Errorf("teams: streaming card got no activity id") } - return &teamsStreamingCard{ + c := &teamsStreamingCard{ conn: p.conn, rc: rc, activityID: id, interval: p.streamInterval(), - }, nil + clear: func() { p.clearCard(rc.conversationID) }, + } + p.registerCard(rc.conversationID, c) + return c, nil } // teamsStreamingCard edits one Adaptive Card in place as the answer streams. @@ -58,6 +61,8 @@ type teamsStreamingCard struct { activityID string interval time.Duration + clear func() // deregisters this card from the platform's active-card map (set at creation) + mu sync.Mutex lastSent time.Time lastText string @@ -97,6 +102,10 @@ func (c *teamsStreamingCard) Finalize(ctx context.Context, content string) error return nil // already terminal — don't re-PUT (matches Slack/DingTalk) } c.mu.Unlock() + // Terminal either way: this card is no longer the conversation's active card. + if c.clear != nil { + defer c.clear() + } if err := c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, answerCard(content))); err != nil { c.mu.Lock() c.failed = true diff --git a/platform/teams/streaming_card_test.go b/platform/teams/streaming_card_test.go index 74516b028..0876e8d3e 100644 --- a/platform/teams/streaming_card_test.go +++ b/platform/teams/streaming_card_test.go @@ -153,3 +153,43 @@ func TestStreamingCard_UpdateErrorIsNonFatal(t *testing.T) { t.Error("a mid-stream update error must not mark the card failed") } } + +func TestActiveCard_RegisteredThenClearedOnFinalize(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + card, err := p.CreateStreamingCard(context.Background(), cardCtx()) + if err != nil { + t.Fatalf("CreateStreamingCard: %v", err) + } + if got, ok := p.activeCard("c1"); !ok || got == nil { + t.Fatal("card should be registered as active after creation") + } + if err := card.Finalize(context.Background(), "done"); err != nil { + t.Fatalf("Finalize: %v", err) + } + if _, ok := p.activeCard("c1"); ok { + t.Error("card should be cleared from active map after Finalize") + } +} + +func TestActiveCard_FailedCardNotReturned(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + if _, err := p.CreateStreamingCard(context.Background(), cardCtx()); err != nil { + t.Fatalf("CreateStreamingCard: %v", err) + } + // Force the registered card terminal, then confirm it is not handed out as active. + p.activeCardsMu.Lock() + p.activeCards["c1"].failed = true + p.activeCardsMu.Unlock() + if _, ok := p.activeCard("c1"); ok { + t.Error("a failed card must not be returned as the active card") + } +} + +func TestActiveCard_UnknownConversation(t *testing.T) { + p := &Platform{conn: &fakeSender{id: "m1"}, cfg: config{}} + if _, ok := p.activeCard("nope"); ok { + t.Error("unknown conversation must return no active card") + } +} diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 9e0350db3..e4551a6c3 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -12,6 +12,7 @@ import ( "fmt" "log/slog" "net/http" + "sync" "time" "github.com/chenhg5/cc-connect/core" @@ -38,6 +39,41 @@ type Platform struct { conn sender server *http.Server dispatchSem chan struct{} // bounds concurrent async dispatch goroutines + + // activeCards maps a conversation id to its live streaming card so an + // interactive prompt (permission / AskUserQuestion) can be folded into the + // card in place rather than posted as a separate message. Registered in + // CreateStreamingCard, cleared when the card finalizes. + activeCardsMu sync.Mutex + activeCards map[string]*teamsStreamingCard +} + +// registerCard records the live streaming card for a conversation. +func (p *Platform) registerCard(conversationID string, c *teamsStreamingCard) { + p.activeCardsMu.Lock() + defer p.activeCardsMu.Unlock() + if p.activeCards == nil { + p.activeCards = make(map[string]*teamsStreamingCard) + } + p.activeCards[conversationID] = c +} + +// clearCard drops the live streaming card for a conversation (idempotent). +func (p *Platform) clearCard(conversationID string) { + p.activeCardsMu.Lock() + defer p.activeCardsMu.Unlock() + delete(p.activeCards, conversationID) +} + +// activeCard returns the live, non-terminal streaming card for a conversation. +func (p *Platform) activeCard(conversationID string) (*teamsStreamingCard, bool) { + p.activeCardsMu.Lock() + c, ok := p.activeCards[conversationID] + p.activeCardsMu.Unlock() + if !ok || c.Failed() { + return nil, false + } + return c, true } // sender abstracts the Bot Connector calls for testability. From 8f76573d60f49b38d238462028752beaab4cfa7f Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 10:59:44 +0200 Subject: [PATCH 22/34] feat(teams): render Action.Submit buttons in Adaptive Cards Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/card.go | 38 +++++++++++++++++++++++++++++++++++ platform/teams/card_test.go | 40 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/platform/teams/card.go b/platform/teams/card.go index a8d85b851..b52cec8e8 100644 --- a/platform/teams/card.go +++ b/platform/teams/card.go @@ -41,6 +41,44 @@ func aiGeneratedEntity() map[string]any { } } +// cardButton is a labeled Action.Submit on a card. action is the value the +// inbound cardAction() parser reads back (e.g. "perm:allow", "askq:0:1"). +type cardButton struct { + title string + action string +} + +// actionSet renders card buttons as an Adaptive Card actions array. Each entry +// is an Action.Submit carrying {action:} in its data, matching the key +// cardAction() reads on the inbound submit. +func actionSet(buttons []cardButton) []map[string]any { + if len(buttons) == 0 { + return nil + } + actions := make([]map[string]any, 0, len(buttons)) + for _, b := range buttons { + actions = append(actions, map[string]any{ + "type": "Action.Submit", + "title": b.title, + "data": map[string]any{"action": b.action}, + }) + } + return actions +} + +// promptCard renders prompt text (Adaptive Card markdown) with a set of +// Action.Submit buttons — used to fold a permission / AskUserQuestion prompt +// into a card. With no buttons it degrades to a plain text card. +func promptCard(markdown string, buttons []cardButton) map[string]any { + card := adaptiveCard([]map[string]any{ + {"type": "TextBlock", "text": markdown, "wrap": true}, + }) + if a := actionSet(buttons); a != nil { + card["actions"] = a + } + return card +} + func adaptiveCard(body []map[string]any) map[string]any { return map[string]any{ "type": "AdaptiveCard", diff --git a/platform/teams/card_test.go b/platform/teams/card_test.go index 661bfc69b..e988d1bda 100644 --- a/platform/teams/card_test.go +++ b/platform/teams/card_test.go @@ -1,6 +1,7 @@ package teams import ( + "encoding/json" "strings" "testing" ) @@ -75,3 +76,42 @@ func TestCardActivity_WrapsAttachment(t *testing.T) { t.Errorf("contentType = %q", a.Attachments[0].ContentType) } } + +func TestPromptCard_RendersActionSubmitButtons(t *testing.T) { + card := promptCard("**needs permission**", []cardButton{ + {title: "Allow", action: "perm:allow"}, + {title: "Deny", action: "perm:deny"}, + }) + actions, ok := card["actions"].([]map[string]any) + if !ok || len(actions) != 2 { + t.Fatalf("want 2 actions, got %v", card["actions"]) + } + if actions[0]["type"] != "Action.Submit" || actions[0]["title"] != "Allow" { + t.Errorf("action[0] = %v", actions[0]) + } + data0, _ := actions[0]["data"].(map[string]any) + if data0["action"] != "perm:allow" { + t.Errorf("action[0] data = %v", actions[0]["data"]) + } +} + +func TestPromptCard_NoButtonsOmitsActions(t *testing.T) { + card := promptCard("just text", nil) + if _, ok := card["actions"]; ok { + t.Errorf("a buttonless prompt card must omit the actions key: %v", card) + } +} + +func TestPromptCard_ActionRoundTripsThroughCardAction(t *testing.T) { + card := promptCard("q", []cardButton{{title: "Option A", action: "askq:0:1"}}) + actions := card["actions"].([]map[string]any) + data, err := json.Marshal(actions[0]["data"]) + if err != nil { + t.Fatalf("marshal data: %v", err) + } + // The outbound button data must be readable by the inbound cardAction() parser. + a := &activity{Value: json.RawMessage(data)} + if got := a.cardAction(); got != "askq:0:1" { + t.Errorf("cardAction() = %q, want askq:0:1", got) + } +} From 3661eb4ef7c637b67ed271e2585c89b6b4dd62ea Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:01:27 +0200 Subject: [PATCH 23/34] feat(teams): implement InlineButtonSender folding prompts into the card Permission and single-select AskUserQuestion prompts now render as Action.Submit buttons folded into the live streaming card (or a standalone card when none is active), instead of leaking as a separate plain-text message. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/buttons.go | 47 ++++++++++++++++ platform/teams/buttons_test.go | 97 ++++++++++++++++++++++++++++++++ platform/teams/streaming_card.go | 20 +++++++ platform/teams/teams.go | 1 + 4 files changed, 165 insertions(+) create mode 100644 platform/teams/buttons.go create mode 100644 platform/teams/buttons_test.go diff --git a/platform/teams/buttons.go b/platform/teams/buttons.go new file mode 100644 index 000000000..b284c80ef --- /dev/null +++ b/platform/teams/buttons.go @@ -0,0 +1,47 @@ +package teams + +import ( + "context" + "fmt" + + "github.com/chenhg5/cc-connect/core" +) + +// SendWithButtons implements core.InlineButtonSender. The engine calls it for +// interactive prompts (tool permission gates and single-select AskUserQuestion) +// before falling back to plain text. When a streaming card is live for the +// conversation, the prompt and its buttons are folded into that card in place; +// otherwise a standalone Adaptive Card carrying the buttons is sent (threaded to +// the originating activity like a text Reply). The button Data (e.g. "perm:allow", +// "askq:0:1") rides in each Action.Submit so cardAction() resolves it inbound. +func (p *Platform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error { + rc, ok := replyCtx.(replyContext) + if !ok { + return fmt.Errorf("teams: invalid reply context %T", replyCtx) + } + cbs := flattenButtons(buttons) + + if card, ok := p.activeCard(rc.conversationID); ok { + return card.promptUpdate(ctx, content, cbs) + } + + a := cardActivity(rc, promptCard(content, cbs)) + if rc.activityID != "" { + return p.conn.replyTo(ctx, rc, rc.activityID, a) + } + _, err := p.conn.send(ctx, rc, a) + return err +} + +// flattenButtons maps the engine's button-grid to a flat []cardButton (Teams +// renders Action.Submit as a single action list; the engine's row grouping is +// not preserved). +func flattenButtons(rows [][]core.ButtonOption) []cardButton { + var out []cardButton + for _, row := range rows { + for _, b := range row { + out = append(out, cardButton{title: b.Text, action: b.Data}) + } + } + return out +} diff --git a/platform/teams/buttons_test.go b/platform/teams/buttons_test.go new file mode 100644 index 000000000..c81cb225f --- /dev/null +++ b/platform/teams/buttons_test.go @@ -0,0 +1,97 @@ +package teams + +import ( + "context" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +var permButtons = [][]core.ButtonOption{ + {{Text: "Allow", Data: "perm:allow"}, {Text: "Deny", Data: "perm:deny"}}, + {{Text: "Allow all", Data: "perm:allow_all"}}, +} + +// cardActions pulls the Action.Submit action strings out of a card attachment. +func cardActions(t *testing.T, a outboundActivity) []string { + t.Helper() + if len(a.Attachments) != 1 { + t.Fatalf("want 1 attachment, got %d", len(a.Attachments)) + } + card, ok := a.Attachments[0].Content.(map[string]any) + if !ok { + t.Fatalf("attachment content is not a card: %T", a.Attachments[0].Content) + } + actions, ok := card["actions"].([]map[string]any) + if !ok { + return nil + } + var out []string + for _, act := range actions { + data, _ := act["data"].(map[string]any) + if s, _ := data["action"].(string); s != "" { + out = append(out, s) + } + } + return out +} + +func TestSendWithButtons_FoldsIntoActiveCard(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + if _, err := p.CreateStreamingCard(context.Background(), cardCtx()); err != nil { + t.Fatalf("CreateStreamingCard: %v", err) + } + + if err := p.SendWithButtons(context.Background(), cardCtx(), "needs permission", permButtons); err != nil { + t.Fatalf("SendWithButtons: %v", err) + } + // Folded via the card's in-place edit (update), not a fresh send/replyTo. + if len(fs.updates) == 0 { + t.Fatalf("prompt should fold into the active card via update, got updates=%d replied=%d", len(fs.updates), len(fs.replied)) + } + got := cardActions(t, fs.updates[len(fs.updates)-1]) + if len(got) != 3 || got[0] != "perm:allow" || got[2] != "perm:allow_all" { + t.Errorf("folded card actions = %v", got) + } +} + +func TestSendWithButtons_NoActiveCardSendsStandalone(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} + + if err := p.SendWithButtons(context.Background(), rc, "needs permission", permButtons); err != nil { + t.Fatalf("SendWithButtons: %v", err) + } + if len(fs.replied) != 1 { + t.Fatalf("no active card -> standalone threaded card, got replied=%d updates=%d", len(fs.replied), len(fs.updates)) + } + got := cardActions(t, fs.replied[0]) + if len(got) != 3 { + t.Errorf("standalone card actions = %v", got) + } +} + +func TestSendWithButtons_UnthreadedUsesSend(t *testing.T) { + fs := &fakeSender{id: "m1"} + p := &Platform{conn: fs, cfg: config{}} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1"} // no activityID, no card + + if err := p.SendWithButtons(context.Background(), rc, "q", [][]core.ButtonOption{{{Text: "A", Data: "askq:0:1"}}}); err != nil { + t.Fatalf("SendWithButtons: %v", err) + } + if len(fs.replied) != 0 || len(fs.last.Attachments) != 1 { + t.Fatalf("unthreaded no-card should use send, got replied=%d last=%+v", len(fs.replied), fs.last) + } + if got := cardActions(t, fs.last); len(got) != 1 || got[0] != "askq:0:1" { + t.Errorf("askq action = %v", got) + } +} + +func TestSendWithButtons_InvalidReplyCtx(t *testing.T) { + p := &Platform{conn: &fakeSender{}, cfg: config{}} + if err := p.SendWithButtons(context.Background(), "nope", "x", permButtons); err == nil { + t.Fatal("want error for invalid reply context") + } +} diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go index 50bc7b417..4c0e9a2da 100644 --- a/platform/teams/streaming_card.go +++ b/platform/teams/streaming_card.go @@ -115,6 +115,26 @@ func (c *teamsStreamingCard) Finalize(ctx context.Context, content string) error return nil } +// promptUpdate PUTs the card with the answer so far plus a folded interactive +// prompt and its Action.Submit buttons, bypassing the throttle (a prompt must +// render at once). The buttons vanish on the next Update, which re-renders the +// plain answer. No-op on a terminal card. +func (c *teamsStreamingCard) promptUpdate(ctx context.Context, prompt string, buttons []cardButton) error { + c.mu.Lock() + if c.failed { + c.mu.Unlock() + return nil + } + answer := c.lastText + c.mu.Unlock() + + body := prompt + if answer != "" { + body = answer + "\n\n" + prompt + } + return c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, promptCard(body, buttons))) +} + // Failed reports whether the card hit a terminal error. func (c *teamsStreamingCard) Failed() bool { c.mu.Lock() diff --git a/platform/teams/teams.go b/platform/teams/teams.go index e4551a6c3..f83003b7d 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -91,6 +91,7 @@ var ( _ core.FormattingInstructionProvider = (*Platform)(nil) _ core.StreamingCardPlatform = (*Platform)(nil) _ core.ImageSender = (*Platform)(nil) + _ core.InlineButtonSender = (*Platform)(nil) ) // New builds a Teams platform from the config.toml options table. From 36824ef23a0d05a9e58686638f8712642833df08 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:05:23 +0200 Subject: [PATCH 24/34] feat(teams): map permission card-action clicks to resolver keywords Inbound Action.Submit perm:* values now map to allow/deny/allow-all text so 'allow all' is not mis-resolved as a one-time allow; askq:* forwarded verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/activity.go | 19 +++++++++++ platform/teams/session_test.go | 58 ++++++++++++++++++++++++++++++++++ platform/teams/webhook.go | 2 +- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/platform/teams/activity.go b/platform/teams/activity.go index 68a4c708d..f825f65e2 100644 --- a/platform/teams/activity.go +++ b/platform/teams/activity.go @@ -169,3 +169,22 @@ func (a *activity) cardAction() string { } return "" } + +// cardActionReply maps a card Action.Submit value to the message content the +// engine's interactive-prompt resolvers expect. Permission actions become the +// canonical keyword text — notably so "perm:allow_all" is not mis-read as a +// one-time allow by the engine's substring/token matcher. AskUserQuestion +// actions ("askq:q:o") and anything else pass through verbatim (the engine +// parses the askq: prefix directly). +func cardActionReply(action string) string { + switch action { + case "perm:allow": + return "allow" + case "perm:deny": + return "deny" + case "perm:allow_all": + return "allow all" + default: + return action + } +} diff --git a/platform/teams/session_test.go b/platform/teams/session_test.go index 332a53a4e..619c61ce6 100644 --- a/platform/teams/session_test.go +++ b/platform/teams/session_test.go @@ -626,3 +626,61 @@ func TestDispatch_SelfMessageIgnored(t *testing.T) { t.Fatalf("bot self-message must be ignored, got %d", len(*got)) } } + +func TestDispatch_CardActionMapsToResolverInput(t *testing.T) { + for _, tc := range []struct{ action, want string }{ + {"perm:allow", "allow"}, + {"perm:deny", "deny"}, + {"perm:allow_all", "allow all"}, // must NOT collapse to a one-time allow + {"askq:0:1", "askq:0:1"}, // AskUserQuestion answer forwarded verbatim + } { + t.Run(tc.action, func(t *testing.T) { + p := teamsPlatform("user") + h, got := collector() + p.handler = h + a := activity{ + Type: "message", + ID: "act-1", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "dm-1", ConversationType: "personal"}, + Value: json.RawMessage(`{"action":"` + tc.action + `"}`), + } + p.dispatch(nil, mustJSON(a)) + if len(*got) != 1 { + t.Fatalf("card action should dispatch one message, got %d", len(*got)) + } + m := (*got)[0] + if m.Content != tc.want { + t.Errorf("content = %q, want %q", m.Content, tc.want) + } + if !m.IsPermissionResponse { + t.Error("card action must set IsPermissionResponse") + } + }) + } +} + +func TestDispatch_MalformedCardValueFallsThroughToText(t *testing.T) { + p := teamsPlatform("user") + h, got := collector() + p.handler = h + a := activity{ + Type: "message", + ID: "act-1", + Text: "hello", + ServiceURL: "https://smba.example/", + From: channelAccount{ID: "user-1"}, + Recipient: channelAccount{ID: "bot-1"}, + Conversation: conversationAccount{ID: "dm-1", ConversationType: "personal"}, + Value: json.RawMessage(`{"foo":"bar"}`), // no action key -> not a card action + } + p.dispatch(nil, mustJSON(a)) + if len(*got) != 1 { + t.Fatalf("text with a non-action value should dispatch as text, got %d", len(*got)) + } + if m := (*got)[0]; m.Content != "hello" || m.IsPermissionResponse { + t.Errorf("expected plain text dispatch, got content=%q isPerm=%v", m.Content, m.IsPermissionResponse) + } +} diff --git a/platform/teams/webhook.go b/platform/teams/webhook.go index f5254aec0..85bfc9ac3 100644 --- a/platform/teams/webhook.go +++ b/platform/teams/webhook.go @@ -137,7 +137,7 @@ func (p *Platform) dispatch(claims jwt.MapClaims, body []byte) { ReplyCtx: rc, } if isCardAction { - msg.Content = action + msg.Content = cardActionReply(action) msg.IsPermissionResponse = true } else { msg.Content = content From ff290a513e26d8ef7848f331e92d50e01d3ff142 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:05:48 +0200 Subject: [PATCH 25/34] docs(teams): document interactive permission/question card buttons Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/teams.md | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab6582b..0f1af89bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **`agent_session_idle_timeout_mins`**: new per-project config option that closes an idle live agent process after a clean turn while preserving the cc-connect session and saved agent session ID. The next message starts a new agent process and resumes the same conversation. Set to `0` or leave unset to disable (#1338). - **Reasonix agent**: new agent adapter for Reasonix multi-model coding agent, bridging via HTTP serve API (POST /submit, SSE /events, POST /approve). Supports default/yolo/plan permission modes, SSE auto-reconnect with backoff, and thinking accumulator. (#1281) - **cloud_web platform**: 新增 self-hosted IM Gateway 作为 first-class platform 接入 (CWIP v1 协议,支持 websocket / long_poll / gateway 3 种 transport,完整 inbound/outbound + capability negotiation + graceful degradation)。 详见 docs/cloud-web.md + #1282。 -- **Microsoft Teams platform**: new Bot Framework connector — inbound webhook with JWT + serviceURL validation, outbound Bot Connector REST with AAD auth, streaming Adaptive Card replies (working card → answer, native AI label), inbound 1:1 files/images, outbound inline image send (size-capped), mention-to-engage with per-thread follow, and `session_scope` (thread/channel/user). See docs/teams.md. +- **Microsoft Teams platform**: new Bot Framework connector — inbound webhook with JWT + serviceURL validation, outbound Bot Connector REST with AAD auth, streaming Adaptive Card replies (working card → answer, native AI label), inbound 1:1 files/images, outbound inline image send (size-capped), interactive permission & AskUserQuestion buttons folded into the streaming card, mention-to-engage with per-thread follow, and `session_scope` (thread/channel/user). See docs/teams.md. ## Unreleased diff --git a/docs/teams.md b/docs/teams.md index d96741fb2..853ab6e9a 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -165,8 +165,11 @@ which POSTs to your endpoint. - Replies stream as an Adaptive Card only. Plain text streaming, a `reply_format` toggle, and the native `streamType` 1:1 animation are deferred to follow-ups. -- Permission prompts render as plain text with numbered options (reply with a - number or `yes`); interactive Adaptive Card buttons are deferred. +- Permission prompts and single-select AskUserQuestion prompts render as + **Adaptive Card buttons folded into the streaming card** (Allow / Deny / + Allow-all; one button per option). Replying with text (`allow`, `deny`, a + number) still works. multiSelect questions stay text-reply — buttons would + resolve on the first tap. - Inbound files and images are supported in **1:1 chats only** (see "Receiving files and images"); channel/group attachments and inbound audio are not. - No cron/timer → Teams proactive messages (a conversation-reference store is From bf03c7b28f0a9a4fbede7fc1293a991df9914620 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:14:35 +0200 Subject: [PATCH 26/34] fix(review): correct multiSelect docs; evict stale card on new stream Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/teams.md | 10 +++++----- platform/teams/streaming_card.go | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/teams.md b/docs/teams.md index 853ab6e9a..333128d0e 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -165,11 +165,11 @@ which POSTs to your endpoint. - Replies stream as an Adaptive Card only. Plain text streaming, a `reply_format` toggle, and the native `streamType` 1:1 animation are deferred to follow-ups. -- Permission prompts and single-select AskUserQuestion prompts render as - **Adaptive Card buttons folded into the streaming card** (Allow / Deny / - Allow-all; one button per option). Replying with text (`allow`, `deny`, a - number) still works. multiSelect questions stay text-reply — buttons would - resolve on the first tap. +- Permission prompts and AskUserQuestion prompts render as **Adaptive Card + buttons folded into the streaming card** (Allow / Deny / Allow-all; one button + per option). Replying with text (`allow`, `deny`, a number) still works. + multiSelect questions also render as buttons and resolve on the **first tap** + (no multi-pick) — reply with comma-separated numbers as text to pick several. - Inbound files and images are supported in **1:1 chats only** (see "Receiving files and images"); channel/group attachments and inbound audio are not. - No cron/timer → Teams proactive messages (a conversation-reference store is diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go index 4c0e9a2da..5e0d41958 100644 --- a/platform/teams/streaming_card.go +++ b/platform/teams/streaming_card.go @@ -36,6 +36,11 @@ func (p *Platform) streamInterval() time.Duration { // createCardStream posts the loading Adaptive Card immediately and returns a // handle that edits it in place. func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core.StreamingCard, error) { + // Evict any stale card for this conversation up front: a prior turn that + // ended without Finalize (idle timeout, cancel, send error) leaves its entry + // registered, and it must not be folded into by this turn's prompts. A + // successful send below re-registers; a failed one leaves it cleared. + p.clearCard(rc.conversationID) id, err := p.conn.send(ctx, rc, aiCardActivity(rc, loadingCard(p.cfg.cardLoadingText))) if err != nil { return nil, err From 388f076ecd56ca8774fb8b39e2a708f5de4d86a9 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:35:42 +0200 Subject: [PATCH 27/34] feat(teams): map Bot Connector 413 to errActivityTooLarge sentinel Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/connector.go | 9 +++++++++ platform/teams/connector_test.go | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/platform/teams/connector.go b/platform/teams/connector.go index f4f4c47f9..41c6af5a9 100644 --- a/platform/teams/connector.go +++ b/platform/teams/connector.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,6 +16,11 @@ import ( // attacker-controlled serviceURL cannot hang a request goroutine indefinitely. const connectorTimeout = 30 * time.Second +// errActivityTooLarge is returned when the Bot Connector rejects an activity as +// too large (HTTP 413). It lets callers (SendImage) degrade to a user notice on +// the real service limit rather than guessing a byte cap up front. +var errActivityTooLarge = errors.New("teams: activity too large (413)") + // outboundActivity is the JSON body POSTed/PUT to the Bot Connector. The // from/recipient/conversation envelope mirrors what the Bot Framework SDKs send // on every outbound activity (apply_conversation_reference). @@ -202,6 +208,9 @@ func (c *connector) do(ctx context.Context, method, url string, a outboundActivi } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if resp.StatusCode == http.StatusRequestEntityTooLarge { + return nil, fmt.Errorf("teams: connector returned 413: %w", errActivityTooLarge) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("teams: connector returned %d", resp.StatusCode) } diff --git a/platform/teams/connector_test.go b/platform/teams/connector_test.go index 47837e4aa..3da1b06f7 100644 --- a/platform/teams/connector_test.go +++ b/platform/teams/connector_test.go @@ -3,6 +3,7 @@ package teams import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -330,3 +331,17 @@ func TestAttachmentMarshal_CardOmitsMediaKeys(t *testing.T) { t.Errorf("card attachment should omit name: %s", b) } } + +func TestConnectorSend_413MapsToActivityTooLarge(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write([]byte(`payload too large`)) + })) + defer srv.Close() + + c := newConnector(&staticTokens{value: "t"}) + _, err := c.send(context.Background(), replyContext{serviceURL: srv.URL, conversationID: "c"}, outboundActivity{Type: "message"}) + if !errors.Is(err, errActivityTooLarge) { + t.Fatalf("413 must map to errActivityTooLarge, got %v", err) + } +} From 8fd01b0399f904099a62e9d5320d3208b65c22ba Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:37:32 +0200 Subject: [PATCH 28/34] feat(teams): degrade to notice on connector 413, drop guessed image cap SendImage now lets Teams define the size limit: it attempts the send and, on a 413 (errActivityTooLarge), degrades to the oversize notice. The former 1 MiB notice-trigger became a generous 20 MiB pathological-size guard, so images Teams would accept (observed working well above 1 MiB) are no longer pre-rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/media_test.go | 18 ++++++++++++++++++ platform/teams/reply_test.go | 19 +++++++++++++++++-- platform/teams/teams.go | 32 ++++++++++++++++++++------------ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/platform/teams/media_test.go b/platform/teams/media_test.go index da153a260..19ece9244 100644 --- a/platform/teams/media_test.go +++ b/platform/teams/media_test.go @@ -102,6 +102,24 @@ func TestSendImage_OversizeSendsNoticeNotImage(t *testing.T) { } } +func TestSendImage_ConnectorTooLargeDegradesToNotice(t *testing.T) { + fs := &fakeSender{id: "m1", attachErr: errActivityTooLarge} + p := &Platform{conn: fs} + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} + + if err := p.SendImage(context.Background(), rc, core.ImageAttachment{Data: []byte{1, 2, 3}, MimeType: "image/png"}); err != nil { + t.Fatalf("a 413 from the connector should degrade to a notice, got %v", err) + } + // First reply is the image (rejected 413); the notice text follows. + if len(fs.replied) != 2 { + t.Fatalf("want image attempt + notice reply, got %d", len(fs.replied)) + } + notice := fs.replied[1] + if notice.Text != oversizeImageNotice || len(notice.Attachments) != 0 { + t.Errorf("last reply should be the plain notice, got %+v", notice) + } +} + func TestSendImage_InvalidReplyCtx(t *testing.T) { p := &Platform{conn: &fakeSender{}} if err := p.SendImage(context.Background(), "not-a-reply-ctx", core.ImageAttachment{Data: []byte{1}}); err == nil { diff --git a/platform/teams/reply_test.go b/platform/teams/reply_test.go index fb26c789a..25016ffa3 100644 --- a/platform/teams/reply_test.go +++ b/platform/teams/reply_test.go @@ -10,6 +10,7 @@ type fakeSender struct { rc replyContext id string err error // injected error for send/replyTo/update + attachErr error // if set, returned by send/replyTo only for an activity carrying a media attachment (ContentURL); lets a test fail an image send while a text notice succeeds updates []outboundActivity updatedIDs []string replied []outboundActivity @@ -36,16 +37,30 @@ func (f *fakeSender) fetch(_ context.Context, url string, withToken bool, _ int6 return f.fetchDefault.data, f.fetchDefault.outcome } +// errFor returns attachErr for a media-bearing activity (an attachment with a +// ContentURL), else the generic injected err. Lets a test fail an image send +// with a specific error while a following text reply (the notice) succeeds. +func (f *fakeSender) errFor(a outboundActivity) error { + if f.attachErr != nil { + for _, att := range a.Attachments { + if att.ContentURL != "" { + return f.attachErr + } + } + } + return f.err +} + func (f *fakeSender) send(_ context.Context, rc replyContext, a outboundActivity) (string, error) { f.last = a f.rc = rc - return f.id, f.err + return f.id, f.errFor(a) } func (f *fakeSender) replyTo(_ context.Context, _ replyContext, activityID string, a outboundActivity) error { f.replied = append(f.replied, a) f.repliedToID = append(f.repliedToID, activityID) - return f.err + return f.errFor(a) } func (f *fakeSender) update(_ context.Context, _ replyContext, activityID string, a outboundActivity) error { diff --git a/platform/teams/teams.go b/platform/teams/teams.go index f83003b7d..7c0df9daf 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -9,6 +9,7 @@ package teams import ( "context" + "errors" "fmt" "log/slog" "net/http" @@ -169,21 +170,23 @@ func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error return err } -// maxOutboundImageBytes caps a single outbound image. Images ride inline as a -// base64 data: URI, which inflates the payload ~33% and is bounded by the Bot -// Connector's activity size limit; a conservative cap keeps a large image from -// being rejected wholesale. Kept a constant (not config) until a deployment -// needs to tune it. Oversize images degrade to a text notice, not a failed turn. -const maxOutboundImageBytes = 1 << 20 // 1 MiB +// maxOutboundImageBytes is a pathological-size safety guard, NOT the Teams +// limit. The real ceiling is enforced by catching the Bot Connector's 413 +// (errActivityTooLarge) and degrading to a notice, so Teams — not a guessed +// constant — decides what's too big. This generous bound only stops a runaway +// image from being base64-encoded into memory and timing out the send; it +// mirrors the inbound max_attachment_bytes default. +const maxOutboundImageBytes = 20 << 20 // 20 MiB -// oversizeImageNotice is sent in place of an image too large to deliver inline. +// oversizeImageNotice is sent in place of an image Teams rejected as too large. // A user-facing i18n key is a possible follow-up; kept a literal for now, // mirroring attachmentFailureNotice on the inbound side. const oversizeImageNotice = "⚠️ I couldn't send an image — it's too large to deliver in Teams." // SendImage delivers an image as an inline attachment, threaded to the -// originating activity like a text Reply. An image larger than the cap degrades -// to a text notice so the turn is not lost. +// originating activity like a text Reply. If the Bot Connector rejects it as too +// large (413), or it exceeds the pathological-size guard, the image degrades to +// a text notice so the turn is not lost. func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error { rc, ok := replyCtx.(replyContext) if !ok { @@ -193,11 +196,16 @@ func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAt return p.Reply(ctx, rc, oversizeImageNotice) } a := imageActivity(rc, img) + var err error if rc.activityID == "" { - _, err := p.conn.send(ctx, rc, a) - return err + _, err = p.conn.send(ctx, rc, a) + } else { + err = p.conn.replyTo(ctx, rc, rc.activityID, a) + } + if errors.Is(err, errActivityTooLarge) { + return p.Reply(ctx, rc, oversizeImageNotice) } - return p.conn.replyTo(ctx, rc, rc.activityID, a) + return err } // ReconstructReplyCtx rebuilds a reply context from a session key. Only the From ee61beba7e4ab21af2472601c85e74180eeb5be8 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:37:53 +0200 Subject: [PATCH 29/34] docs(teams): image size limit is Teams' 413, not a fixed cap Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/teams.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/teams.md b/docs/teams.md index 333128d0e..360e06497 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -142,11 +142,12 @@ attachments. When the agent produces an image (or a user runs `cc-connect send --image`), the bot sends it back **inline** as a base64 data-URI attachment, threaded to the -originating message. It renders in 1:1 chats, group chats, and channels. Images -above ~1 MiB are skipped with a brief text notice rather than failing the turn, -because inline delivery is bounded by the Bot Connector's activity size limit. -Sending **files** (non-image) is not supported — Teams requires a separate file -consent / SharePoint flow. +originating message. It renders in 1:1 chats, group chats, and channels. If the +Bot Connector rejects the image as too large (HTTP 413), the bot degrades to a +brief text notice rather than failing the turn — so Teams' own size limit +governs, not a fixed cap (a generous safety guard only rejects pathologically +large images up front). Sending **files** (non-image) is not supported — Teams +requires a separate file consent / SharePoint flow. **Not supported:** - **Channel / group attachments.** Files posted in a channel or group chat live From 9bebfbec1ee93e1ba28fb3ca9ca585a9256a07e5 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 11:41:33 +0200 Subject: [PATCH 30/34] fix(review): inject wrapped 413 error in SendImage degradation test Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/media_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platform/teams/media_test.go b/platform/teams/media_test.go index 19ece9244..4e6e807e1 100644 --- a/platform/teams/media_test.go +++ b/platform/teams/media_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "errors" + "fmt" "strings" "testing" @@ -103,7 +104,9 @@ func TestSendImage_OversizeSendsNoticeNotImage(t *testing.T) { } func TestSendImage_ConnectorTooLargeDegradesToNotice(t *testing.T) { - fs := &fakeSender{id: "m1", attachErr: errActivityTooLarge} + // Inject the WRAPPED form the connector actually returns (do() wraps with %w), + // so this exercises the errors.Is unwrap rather than a bare-equality match. + fs := &fakeSender{id: "m1", attachErr: fmt.Errorf("teams: connector returned 413: %w", errActivityTooLarge)} p := &Platform{conn: fs} rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} From 496ba74894e1446f08633841f38e001841deb0d5 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 12:01:25 +0200 Subject: [PATCH 31/34] refactor(teams): send interactive prompts as standalone cards, not folded Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/buttons.go | 19 +++++++------------ platform/teams/buttons_test.go | 20 +++++++++++++------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/platform/teams/buttons.go b/platform/teams/buttons.go index b284c80ef..f5c1aabd0 100644 --- a/platform/teams/buttons.go +++ b/platform/teams/buttons.go @@ -9,23 +9,18 @@ import ( // SendWithButtons implements core.InlineButtonSender. The engine calls it for // interactive prompts (tool permission gates and single-select AskUserQuestion) -// before falling back to plain text. When a streaming card is live for the -// conversation, the prompt and its buttons are folded into that card in place; -// otherwise a standalone Adaptive Card carrying the buttons is sent (threaded to -// the originating activity like a text Reply). The button Data (e.g. "perm:allow", -// "askq:0:1") rides in each Action.Submit so cardAction() resolves it inbound. +// before falling back to plain text. The prompt is always its own standalone +// Adaptive Card carrying the buttons, threaded to the originating activity like a +// text Reply — never folded into the live streaming card, so a prompt is a +// distinct, un-missable timeline message (matches every other platform). The +// button Data (e.g. "perm:allow", "askq:0:1") rides in each Action.Submit so +// cardAction() resolves it inbound. func (p *Platform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error { rc, ok := replyCtx.(replyContext) if !ok { return fmt.Errorf("teams: invalid reply context %T", replyCtx) } - cbs := flattenButtons(buttons) - - if card, ok := p.activeCard(rc.conversationID); ok { - return card.promptUpdate(ctx, content, cbs) - } - - a := cardActivity(rc, promptCard(content, cbs)) + a := cardActivity(rc, promptCard(content, flattenButtons(buttons))) if rc.activityID != "" { return p.conn.replyTo(ctx, rc, rc.activityID, a) } diff --git a/platform/teams/buttons_test.go b/platform/teams/buttons_test.go index c81cb225f..f4985ac92 100644 --- a/platform/teams/buttons_test.go +++ b/platform/teams/buttons_test.go @@ -36,23 +36,29 @@ func cardActions(t *testing.T, a outboundActivity) []string { return out } -func TestSendWithButtons_FoldsIntoActiveCard(t *testing.T) { +func TestSendWithButtons_StandaloneEvenWithActiveCard(t *testing.T) { fs := &fakeSender{id: "m1"} p := &Platform{conn: fs, cfg: config{}} + // Even with a live streaming card for this conversation, the prompt must be + // its own message (a card send), NOT folded into the streaming card via update. if _, err := p.CreateStreamingCard(context.Background(), cardCtx()); err != nil { t.Fatalf("CreateStreamingCard: %v", err) } + updatesBefore := len(fs.updates) - if err := p.SendWithButtons(context.Background(), cardCtx(), "needs permission", permButtons); err != nil { + rc := replyContext{serviceURL: "https://s/", conversationID: "c1", activityID: "a1"} + if err := p.SendWithButtons(context.Background(), rc, "needs permission", permButtons); err != nil { t.Fatalf("SendWithButtons: %v", err) } - // Folded via the card's in-place edit (update), not a fresh send/replyTo. - if len(fs.updates) == 0 { - t.Fatalf("prompt should fold into the active card via update, got updates=%d replied=%d", len(fs.updates), len(fs.replied)) + if len(fs.updates) != updatesBefore { + t.Fatalf("prompt must not fold into the streaming card via update; updates went %d -> %d", updatesBefore, len(fs.updates)) + } + if len(fs.replied) != 1 { + t.Fatalf("prompt should be its own threaded card message, got replied=%d", len(fs.replied)) } - got := cardActions(t, fs.updates[len(fs.updates)-1]) + got := cardActions(t, fs.replied[0]) if len(got) != 3 || got[0] != "perm:allow" || got[2] != "perm:allow_all" { - t.Errorf("folded card actions = %v", got) + t.Errorf("standalone card actions = %v", got) } } From c1d580e587f9017eea55cfeb579440caf18b8348 Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 12:03:03 +0200 Subject: [PATCH 32/34] refactor(teams): remove dead active-card registry and promptUpdate Nothing folds prompts into the streaming card anymore, so the activeCards registry, promptUpdate, and their lifecycle wiring are unused. Removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/streaming_card.go | 38 ++----------------------- platform/teams/streaming_card_test.go | 40 --------------------------- platform/teams/teams.go | 36 ------------------------ 3 files changed, 2 insertions(+), 112 deletions(-) diff --git a/platform/teams/streaming_card.go b/platform/teams/streaming_card.go index 5e0d41958..c82873fba 100644 --- a/platform/teams/streaming_card.go +++ b/platform/teams/streaming_card.go @@ -36,11 +36,6 @@ func (p *Platform) streamInterval() time.Duration { // createCardStream posts the loading Adaptive Card immediately and returns a // handle that edits it in place. func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core.StreamingCard, error) { - // Evict any stale card for this conversation up front: a prior turn that - // ended without Finalize (idle timeout, cancel, send error) leaves its entry - // registered, and it must not be folded into by this turn's prompts. A - // successful send below re-registers; a failed one leaves it cleared. - p.clearCard(rc.conversationID) id, err := p.conn.send(ctx, rc, aiCardActivity(rc, loadingCard(p.cfg.cardLoadingText))) if err != nil { return nil, err @@ -48,15 +43,12 @@ func (p *Platform) createCardStream(ctx context.Context, rc replyContext) (core. if id == "" { return nil, fmt.Errorf("teams: streaming card got no activity id") } - c := &teamsStreamingCard{ + return &teamsStreamingCard{ conn: p.conn, rc: rc, activityID: id, interval: p.streamInterval(), - clear: func() { p.clearCard(rc.conversationID) }, - } - p.registerCard(rc.conversationID, c) - return c, nil + }, nil } // teamsStreamingCard edits one Adaptive Card in place as the answer streams. @@ -66,8 +58,6 @@ type teamsStreamingCard struct { activityID string interval time.Duration - clear func() // deregisters this card from the platform's active-card map (set at creation) - mu sync.Mutex lastSent time.Time lastText string @@ -107,10 +97,6 @@ func (c *teamsStreamingCard) Finalize(ctx context.Context, content string) error return nil // already terminal — don't re-PUT (matches Slack/DingTalk) } c.mu.Unlock() - // Terminal either way: this card is no longer the conversation's active card. - if c.clear != nil { - defer c.clear() - } if err := c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, answerCard(content))); err != nil { c.mu.Lock() c.failed = true @@ -120,26 +106,6 @@ func (c *teamsStreamingCard) Finalize(ctx context.Context, content string) error return nil } -// promptUpdate PUTs the card with the answer so far plus a folded interactive -// prompt and its Action.Submit buttons, bypassing the throttle (a prompt must -// render at once). The buttons vanish on the next Update, which re-renders the -// plain answer. No-op on a terminal card. -func (c *teamsStreamingCard) promptUpdate(ctx context.Context, prompt string, buttons []cardButton) error { - c.mu.Lock() - if c.failed { - c.mu.Unlock() - return nil - } - answer := c.lastText - c.mu.Unlock() - - body := prompt - if answer != "" { - body = answer + "\n\n" + prompt - } - return c.conn.update(ctx, c.rc, c.activityID, aiCardActivity(c.rc, promptCard(body, buttons))) -} - // Failed reports whether the card hit a terminal error. func (c *teamsStreamingCard) Failed() bool { c.mu.Lock() diff --git a/platform/teams/streaming_card_test.go b/platform/teams/streaming_card_test.go index 0876e8d3e..74516b028 100644 --- a/platform/teams/streaming_card_test.go +++ b/platform/teams/streaming_card_test.go @@ -153,43 +153,3 @@ func TestStreamingCard_UpdateErrorIsNonFatal(t *testing.T) { t.Error("a mid-stream update error must not mark the card failed") } } - -func TestActiveCard_RegisteredThenClearedOnFinalize(t *testing.T) { - fs := &fakeSender{id: "m1"} - p := &Platform{conn: fs, cfg: config{}} - card, err := p.CreateStreamingCard(context.Background(), cardCtx()) - if err != nil { - t.Fatalf("CreateStreamingCard: %v", err) - } - if got, ok := p.activeCard("c1"); !ok || got == nil { - t.Fatal("card should be registered as active after creation") - } - if err := card.Finalize(context.Background(), "done"); err != nil { - t.Fatalf("Finalize: %v", err) - } - if _, ok := p.activeCard("c1"); ok { - t.Error("card should be cleared from active map after Finalize") - } -} - -func TestActiveCard_FailedCardNotReturned(t *testing.T) { - fs := &fakeSender{id: "m1"} - p := &Platform{conn: fs, cfg: config{}} - if _, err := p.CreateStreamingCard(context.Background(), cardCtx()); err != nil { - t.Fatalf("CreateStreamingCard: %v", err) - } - // Force the registered card terminal, then confirm it is not handed out as active. - p.activeCardsMu.Lock() - p.activeCards["c1"].failed = true - p.activeCardsMu.Unlock() - if _, ok := p.activeCard("c1"); ok { - t.Error("a failed card must not be returned as the active card") - } -} - -func TestActiveCard_UnknownConversation(t *testing.T) { - p := &Platform{conn: &fakeSender{id: "m1"}, cfg: config{}} - if _, ok := p.activeCard("nope"); ok { - t.Error("unknown conversation must return no active card") - } -} diff --git a/platform/teams/teams.go b/platform/teams/teams.go index 7c0df9daf..f5d13390c 100644 --- a/platform/teams/teams.go +++ b/platform/teams/teams.go @@ -13,7 +13,6 @@ import ( "fmt" "log/slog" "net/http" - "sync" "time" "github.com/chenhg5/cc-connect/core" @@ -40,41 +39,6 @@ type Platform struct { conn sender server *http.Server dispatchSem chan struct{} // bounds concurrent async dispatch goroutines - - // activeCards maps a conversation id to its live streaming card so an - // interactive prompt (permission / AskUserQuestion) can be folded into the - // card in place rather than posted as a separate message. Registered in - // CreateStreamingCard, cleared when the card finalizes. - activeCardsMu sync.Mutex - activeCards map[string]*teamsStreamingCard -} - -// registerCard records the live streaming card for a conversation. -func (p *Platform) registerCard(conversationID string, c *teamsStreamingCard) { - p.activeCardsMu.Lock() - defer p.activeCardsMu.Unlock() - if p.activeCards == nil { - p.activeCards = make(map[string]*teamsStreamingCard) - } - p.activeCards[conversationID] = c -} - -// clearCard drops the live streaming card for a conversation (idempotent). -func (p *Platform) clearCard(conversationID string) { - p.activeCardsMu.Lock() - defer p.activeCardsMu.Unlock() - delete(p.activeCards, conversationID) -} - -// activeCard returns the live, non-terminal streaming card for a conversation. -func (p *Platform) activeCard(conversationID string) (*teamsStreamingCard, bool) { - p.activeCardsMu.Lock() - c, ok := p.activeCards[conversationID] - p.activeCardsMu.Unlock() - if !ok || c.Failed() { - return nil, false - } - return c, true } // sender abstracts the Bot Connector calls for testability. From 0a6bfe7222950036257dc7cc68f41c5938cc812f Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 12:03:29 +0200 Subject: [PATCH 33/34] docs(teams): prompts render as standalone interactive cards Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/teams.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/teams.md b/docs/teams.md index 360e06497..d088d48cb 100644 --- a/docs/teams.md +++ b/docs/teams.md @@ -166,9 +166,10 @@ which POSTs to your endpoint. - Replies stream as an Adaptive Card only. Plain text streaming, a `reply_format` toggle, and the native `streamType` 1:1 animation are deferred to follow-ups. -- Permission prompts and AskUserQuestion prompts render as **Adaptive Card - buttons folded into the streaming card** (Allow / Deny / Allow-all; one button - per option). Replying with text (`allow`, `deny`, a number) still works. +- Permission prompts and AskUserQuestion prompts render as their **own + interactive Adaptive Card** (Allow / Deny / Allow-all; one button per option) — + a distinct message, not folded into the streaming answer card, so a prompt is + never missed. Replying with text (`allow`, `deny`, a number) still works. multiSelect questions also render as buttons and resolve on the **first tap** (no multi-pick) — reply with comma-separated numbers as text to pick several. - Inbound files and images are supported in **1:1 chats only** (see "Receiving From a3c6728b98eb4e1454d145eefbff3498f224193b Mon Sep 17 00:00:00 2001 From: Taras Vuyiv Date: Thu, 9 Jul 2026 13:04:48 +0200 Subject: [PATCH 34/34] fix(teams): check resp.Body.Close() error to satisfy errcheck lint Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/teams/connector.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform/teams/connector.go b/platform/teams/connector.go index 41c6af5a9..c280634cf 100644 --- a/platform/teams/connector.go +++ b/platform/teams/connector.go @@ -170,7 +170,7 @@ func (c *connector) fetch(ctx context.Context, rawURL string, withToken bool, ma if err != nil { return nil, fetchFailed } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fetchFailed } @@ -206,7 +206,7 @@ func (c *connector) do(ctx context.Context, method, url string, a outboundActivi if err != nil { return nil, fmt.Errorf("teams: connector %s: %w", method, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if resp.StatusCode == http.StatusRequestEntityTooLarge { return nil, fmt.Errorf("teams: connector returned 413: %w", errActivityTooLarge)