diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d65f39e..1bd2a0fc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: - "src/**" - "crates/**" - "operator/**" + - "scripts/teams-ack-drop-proxy.py" + - "scripts/test-teams-ack-drop-proxy.py" + - ".github/workflows/ci.yml" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" @@ -13,31 +16,41 @@ on: env: CARGO_TERM_COLOR: always +permissions: + contents: read + jobs: changes: runs-on: ubuntu-latest outputs: core: ${{ steps.filter.outputs.core }} operator: ${{ steps.filter.outputs.operator }} + teams_ack_proxy: ${{ steps.filter.outputs.teams_ack_proxy }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: fetch-depth: 0 + persist-credentials: false - id: filter env: BASE: ${{ github.event.pull_request.base.sha }} HEAD: ${{ github.event.pull_request.head.sha }} run: | CHANGED=$(git diff --name-only "$BASE" "$HEAD") - echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT" - echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT" + { + echo "core=$(echo "$CHANGED" | grep -qE '^(src/|crates/|Cargo\.(toml|lock)|\.github/workflows/ci\.yml$)' && echo true || echo false)" + echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" + echo "teams_ack_proxy=$(echo "$CHANGED" | grep -qE '^(scripts/(teams-ack-drop-proxy|test-teams-ack-drop-proxy)\.py|\.github/workflows/ci\.yml)$' && echo true || echo false)" + } >> "$GITHUB_OUTPUT" check: needs: changes if: needs.changes.outputs.core == 'true' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13) with: components: clippy @@ -94,6 +107,20 @@ jobs: - name: cargo build (unified) run: cargo build --features unified + teams-ack-proxy: + needs: changes + if: needs.changes.outputs.teams_ack_proxy == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - name: Test bounded Teams ACK-drop proxy + run: | + set -euo pipefail + python3 -m py_compile scripts/teams-ack-drop-proxy.py scripts/test-teams-ack-drop-proxy.py + python3 -W error::ResourceWarning scripts/test-teams-ack-drop-proxy.py + operator: needs: changes if: needs.changes.outputs.operator == 'true' @@ -102,7 +129,9 @@ jobs: run: working-directory: operator steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable (2026-07-13) with: components: clippy diff --git a/.gitignore b/.gitignore index fe7eedff9..18e804612 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ gateway/target/ config.toml *.swp .DS_Store +__pycache__/ +*.py[cod] .env .kiro/ diff --git a/Cargo.lock b/Cargo.lock index fc11ec7e5..183cfe2c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2607,8 +2607,10 @@ dependencies = [ "chrono", "futures-util", "hmac 0.12.1", + "httpdate", "image", "jsonwebtoken", + "libc", "parking_lot", "prost", "quick-xml", @@ -2624,6 +2626,7 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "windows-sys 0.61.2", "wiremock", ] diff --git a/README.md b/README.md index a9628a824..970f86411 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,17 @@ See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standa +
+Microsoft Teams (Unified or Custom Gateway) + +Start with the [self-hosted setup guide](docs/msteams-selfhosted.md), or use the +[enterprise Kubernetes guide](docs/msteams-enterprise.md). Before relying on a +specific capability or scope, check the +[live-validation tracker](docs/msteams-live-validation.md). Teams attachment +behavior is documented in [Inbound Attachments](docs/inbound-attachments.md). + +
+ ### 2. Install with Helm (Kiro CLI — default) ```bash @@ -168,6 +179,7 @@ kubectl rollout restart deployment/openab-kiro ### 4. Use In your Discord channel: + ``` @YourBot explain this code ``` diff --git a/README.zh-TW.md b/README.zh-TW.md index c429cdb42..12ce82c39 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -138,6 +138,16 @@ platforms 使用 `webhook/API`,Feishu/Lark 則使用 `WS/webhook`。 +
+Microsoft Teams(Unified 或 Custom Gateway) + +請先閱讀 [self-hosted 設定指南](docs/msteams-selfhosted.md);企業 Kubernetes +部署則使用 [enterprise 指南](docs/msteams-enterprise.md)。依賴特定功能或 +scope 前,請檢查 [live-validation tracker](docs/msteams-live-validation.md)。 +Teams attachment 行為記錄於 [Inbound Attachments](docs/inbound-attachments.md)。 + +
+ ### 2. 使用 Helm 安裝(Kiro CLI — 預設) ```bash @@ -168,6 +178,7 @@ kubectl rollout restart deployment/openab-kiro ### 4. 使用方式 在 Discord 頻道中輸入: + ``` @YourBot explain this code ``` diff --git a/charts/openab/README.md b/charts/openab/README.md index dfac33a38..80c7edfe1 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -36,7 +36,8 @@ Each agent lives under `agents.`. | `nameOverride` | Override this agent's generated resource name. | `""` | | `workingDir` | Working directory and HOME inside the container. | `"/home/agent"` | | `env` | Inline environment variables passed to the agent process. | `{}` | -| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps. | `[]` | +| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps for the OpenAB process. | `[]` | +| `secretEnv` | Individual Secret keys injected into the OpenAB process. Raw `configToml` must explicitly pass only required agent credentials through `[agent].env` or `inherit_env`; adapter and Gateway secrets must not reach the ACP child. | `[]` | | `pool.maxSessions` | Maximum concurrent ACP sessions for the agent. | `10` | | `pool.sessionTtlHours` | Idle session TTL in hours. | `24` | | `reactions.enabled` | Enable status reactions. | `true` | @@ -48,8 +49,13 @@ Each agent lives under `agents.`. | `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` | | `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` | | `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` | -| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | -| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | +| `gateway.teams.reactionsEnabled` | Opt in to Microsoft public-preview Bot Connector reactions. | `false` | +| `gateway.teams.inboundAttachments` | Enable metadata-first Teams image/text ingress on both Core and Gateway. | `false` | +| `gateway.teams.conversationRegistryPath` | Opt in to the Gateway-local persistent Teams conversation registry. Mount the path separately. | `""` | +| `gateway.teams.conversationRegistryMaxEntries` | Persistent Teams registry entry cap. | `1000` | +| `gateway.teams.conversationRegistryTtlSecs` | Active/disabled registry retention window. | `31536000` | +| `configToml` | Raw authoritative `config.toml`, including baseline `[[cron.jobs]]`. Required unless `configUrl` is used. | `""` | +| `configUrl` | External authoritative config URL; mutually exclusive with the rendered ConfigMap path. | `""` | | `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | | `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` | | `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` | @@ -112,6 +118,51 @@ See [`docs/migrate-to-configtoml.md`](../../docs/migrate-to-configtoml.md) for a [`docs/adr/configurl-over-helm-rendering.md`](../../docs/adr/configurl-over-helm-rendering.md) for when to prefer `configUrl` instead (platform-agnostic — works identically on Kubernetes, ECS, Zeabur, and AgentCore). +For Teams typed scope, put the policy in that raw TOML rather than under the Gateway transport values: + +```toml +[teams] +allowed_teams = [] +allowed_channels = [] # both empty = all Team channels; otherwise Team OR channel match +allow_personal = true +allow_group_chats = true +``` + +Presence of any of these four fields opts into typed L2 policy. In Standalone Gateway mode, the policy still belongs to the OpenAB Core `configToml`; `gateway.teams.*` configures transport credentials and reaction preview on the Gateway container. + +`gateway.teams.inboundAttachments=true` is the exception that must stay aligned across processes: the chart emits `TEAMS_INBOUND_ATTACHMENTS=true` into both Core and Gateway. It enables bounded metadata-first image/text materialization only after Core trust admission. When `gateway.deploy=false`, configure the same environment variable on the external Gateway yourself. + +`gateway.teams.conversationRegistryPath` is a separate Gateway-only opt-in. The chart does not silently provision or attach a Gateway PVC; use `gateway.extraVolumeMounts` and `gateway.extraVolumes` (prefer an externally managed PVC with `"helm.sh/resource-policy": keep`) so the configured file survives pod replacement. An empty path preserves the previous process-local behavior and emits no registry environment variables. + +Teams operator cron belongs only in the raw Core `configToml`; the chart does not create a parallel target selector: + +```toml +[[cron.jobs]] +schedule = "0 9 * * 1-5" +platform = "teams" +channel = "" +teams_tenant_id = "" +message = "summarize yesterday's merged work" +timezone = "Asia/Taipei" +``` + +This requires an exact active record in the Gateway registry. `serviceUrl` remains Gateway-local, `thread_id` is invalid for Teams, and agent-writable usercron cannot select the record. + ### Discord ID precision warning Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision. + +## Maintaining This Reference + +- **Trigger:** any change to a commonly documented key, default, generated + resource, or Teams transport/registry value in `values.yaml` or `templates/`. +- **Action:** update the table or example in this file, then run: + + ```bash + helm template test charts/openab --set agents.kiro.enabled=false + helm template test charts/openab \ + --set-file agents.kiro.configToml=config.toml.example + ``` + +- **Why:** [`values.yaml`](values.yaml) and the templates are authoritative; + this README is a curated operator view and must not silently drift from them. diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 3ae0e079b..5473ad9c7 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -88,6 +88,10 @@ spec: name: {{ include "openab.agentFullname" $d }} key: gateway-ws-token {{- end }} + {{- if and ($cfg.gateway).enabled (hasKey (($cfg.gateway).teams) "inboundAttachments") }} + - name: TEAMS_INBOUND_ATTACHMENTS + value: {{ ($cfg.gateway).teams.inboundAttachments | quote }} + {{- end }} - name: HOME value: {{ $cfg.workingDir | default "/home/agent" }} {{- range $k, $v := $cfg.env }} diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 2a89dc79a..03cae558e 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -108,6 +108,22 @@ spec: - name: TEAMS_WEBHOOK_PATH value: {{ ($cfg.gateway).teams.webhookPath | quote }} {{- end }} + {{- if hasKey (($cfg.gateway).teams) "reactionsEnabled" }} + - name: TEAMS_REACTIONS_ENABLED + value: {{ ($cfg.gateway).teams.reactionsEnabled | quote }} + {{- end }} + {{- if hasKey (($cfg.gateway).teams) "inboundAttachments" }} + - name: TEAMS_INBOUND_ATTACHMENTS + value: {{ ($cfg.gateway).teams.inboundAttachments | quote }} + {{- end }} + {{- if ($cfg.gateway).teams.conversationRegistryPath }} + - name: TEAMS_CONVERSATION_REGISTRY_PATH + value: {{ ($cfg.gateway).teams.conversationRegistryPath | quote }} + - name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES + value: {{ ($cfg.gateway).teams.conversationRegistryMaxEntries | int64 | quote }} + - name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS + value: {{ ($cfg.gateway).teams.conversationRegistryTtlSecs | int64 | quote }} + {{- end }} {{- end }} {{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }} {{- if $hasFeishu }} diff --git a/charts/openab/tests/teams_registry_test.yaml b/charts/openab/tests/teams_registry_test.yaml new file mode 100644 index 000000000..fee435651 --- /dev/null +++ b/charts/openab/tests/teams_registry_test.yaml @@ -0,0 +1,57 @@ +suite: Teams persistent conversation registry rendering +templates: + - templates/gateway.yaml + +set: + agents.kiro.gateway.enabled: true + agents.kiro.gateway.teams.appId: test-app + agents.kiro.gateway.teams.appSecret: test-secret + +tests: + - it: keeps registry disabled and adds no volume by default + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_PATH + - notExists: + path: spec.template.spec.volumes + + - it: renders integer defaults after an explicit path + set: + agents.kiro.gateway.teams.conversationRegistryPath: /var/lib/openab/teams/conversations.json + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES + value: "1000" + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS + value: "31536000" + + - it: renders bounded registry settings only after an explicit path + set: + agents.kiro.gateway.teams.conversationRegistryPath: /var/lib/openab/teams/conversations.json + agents.kiro.gateway.teams.conversationRegistryMaxEntries: 123 + agents.kiro.gateway.teams.conversationRegistryTtlSecs: 456 + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_PATH + value: /var/lib/openab/teams/conversations.json + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES + value: "123" + - contains: + path: spec.template.spec.containers[0].env + content: + name: TEAMS_CONVERSATION_REGISTRY_TTL_SECS + value: "456" + - notExists: + path: spec.template.spec.volumes diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index fd37b023c..d8e33e135 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -68,9 +68,10 @@ agents: # nameOverride: "" # env: {} # envFrom: [] - # # secretEnv: inject API keys from a Kubernetes Secret without storing them in the ConfigMap. - # # Each entry renders as valueFrom.secretKeyRef in the Deployment and auto-adds the key name - # # to inherit_env in config.toml. ⚠️ Do NOT also list the same key in env — use one or the other. + # # secretEnv: inject values from a Kubernetes Secret into the OpenAB process + # # without storing them in the ConfigMap. Raw configToml is mounted verbatim; + # # pass only required agent credentials through [agent].env or inherit_env. + # # Adapter and Gateway secrets must stay out of the ACP child environment. # # secretEnv: # # - name: GEMINI_API_KEY # # secretName: my-secrets @@ -102,6 +103,12 @@ agents: # # allowed_channels = ["C01234567"] # # allow_user_messages = "mentions" # # + # # [teams] + # # allowed_teams = [] # Team IDs; both lists empty = all Team channels + # # allowed_channels = [] # a Team OR channel match admits + # # allow_personal = true + # # allow_group_chats = true + # # # # [agent] # # command = "claude-agent-acp" # # inherit_env = ["ANTHROPIC_API_KEY"] @@ -389,7 +396,9 @@ agents: env: {} # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN. envFrom: [] - secretEnv: [] # list of {name, secretName, secretKey} — rendered as valueFrom.secretKeyRef; keys auto-added to inherit_env + # list of {name, secretName, secretKey}; injected into OpenAB only. Raw + # configToml decides explicitly which values, if any, reach the ACP child. + secretEnv: [] pool: maxSessions: 10 sessionTtlHours: 24 @@ -416,7 +425,7 @@ agents: gateway: enabled: false # set to true + provide url to enable the [gateway] config block deploy: true # set to false to skip Gateway Deployment/Service (config-only mode) - url: "" # e.g. ws://openab-gateway:8080/ws + url: "" # WebSocket URL, e.g. the in-cluster openab-gateway Service platform: "telegram" # default platform when gateway is enabled token: "" # optional shared secret (injected via GATEWAY_WS_TOKEN env var) botUsername: "" # optional, for @mention gating @@ -461,6 +470,15 @@ agents: openidMetadata: "" # Override for sovereign clouds → TEAMS_OPENID_METADATA allowedTenants: [] # List of tenant IDs → TEAMS_ALLOWED_TENANTS webhookPath: "" # Gateway default: /webhook/teams → TEAMS_WEBHOOK_PATH + reactionsEnabled: false # Public-preview Bot Connector reactions → TEAMS_REACTIONS_ENABLED + # Default-off metadata-first image/text ingress. Sets the same env on + # Core and Gateway; no Microsoft URL or token crosses into Core. + inboundAttachments: false # → TEAMS_INBOUND_ATTACHMENTS + # Optional trusted persistent conversation registry. The chart does not create a + # Gateway PVC; mount this path with gateway.extraVolumeMounts/extraVolumes. + conversationRegistryPath: "" # disabled → TEAMS_CONVERSATION_REGISTRY_PATH + conversationRegistryMaxEntries: 1000 # → TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES + conversationRegistryTtlSecs: 31536000 # → TEAMS_CONVERSATION_REGISTRY_TTL_SECS # Feishu/Lark adapter config (gateway-side env vars) # See docs/feishu.md for full setup guide feishu: diff --git a/config.toml.example b/config.toml.example index 00add1a9b..0003b439a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -61,6 +61,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # # send-once (streaming is forced off to avoid # # posting duplicate, growing messages) # streaming_placeholder = false # set false for draft-based platforms (e.g. Telegram Rich Messages) +# gateway_ack_timeout_secs = 12 # only enforced for ACKs advertised by a negotiated gateway # --- Telegram (first-class section; alternative to TELEGRAM_* env vars) --- # Config-authoritative with ${} expansion; each field falls back to its @@ -133,7 +134,23 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # app_secret = "${TEAMS_APP_SECRET}" # env fallback: TEAMS_APP_SECRET # allowed_tenants = [""] # env fallback: TEAMS_ALLOWED_TENANTS (empty = all) # webhook_path = "/webhook/teams" # env fallback: TEAMS_WEBHOOK_PATH -# allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS +# dedupe_ttl_secs = 600 # env fallback: TEAMS_DEDUPE_TTL_SECS +# route_ttl_secs = 3600 # env fallback: TEAMS_ROUTE_TTL_SECS +# max_route_entries = 10000 # independent route/dedupe/ownership caps; env: TEAMS_MAX_ROUTE_ENTRIES +# reactions_enabled = false # public-preview reactions; env: TEAMS_REACTIONS_ENABLED +# processing_indicator = "off" # off | message; env: TEAMS_PROCESSING_INDICATOR +# streaming = false # progressive bot-owned edits; env: TEAMS_STREAMING +# inbound_attachments = false # post-trust image/text materialization; env: TEAMS_INBOUND_ATTACHMENTS +# conversation_registry_path = "teams/conversations.json" # opt-in; relative to $HOME/.openab/ +# conversation_registry_max_entries = 1000 # env: TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES +# conversation_registry_ttl_secs = 31536000 # one year; env: TEAMS_CONVERSATION_REGISTRY_TTL_SECS +# allowed_teams = [] # Team IDs; env: TEAMS_ALLOWED_TEAMS (comma-separated) +# allowed_channels = [] # channel IDs; env: TEAMS_ALLOWED_CHANNELS +# # both empty = all Team channels; Team OR channel match +# allow_personal = true # env: TEAMS_ALLOW_PERSONAL +# allow_group_chats = true # env: TEAMS_ALLOW_GROUP_CHATS +# # setting any field above opts into typed scope policy +# allow_all_users = false # independent L3 gate; env: TEAMS_ALLOW_ALL_USERS # allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) # # env fallback: TEAMS_ALLOWED_USERS (comma-separated) @@ -340,10 +357,21 @@ error_hold_ms = 2500 # schedule = "0 9 * * 1-5" # weekdays at 9:00 AM # channel = "123456789" # target channel/thread ID # message = "summarize yesterday's merged PRs" # prompt for the agent -# platform = "discord" # "discord" or "slack" +# platform = "discord" # discord/slack/telegram/googlechat/lineworks/teams # sender_name = "DailyOps" # attribution (default: "openab-cron") # timezone = "America/New_York" # IANA timezone (default: "UTC") -# thread_id = "" # optional: post to existing thread +# thread_id = "" # optional existing thread; rejected for Teams + +# Teams operator baseline: requires an active Gateway conversation-registry record. +# `channel` is the trusted Teams conversation ID; do not configure serviceUrl. +# [[cron.jobs]] +# schedule = "0 9 * * 1-5" +# platform = "teams" +# channel = "" +# teams_tenant_id = "" +# message = "summarize yesterday's merged work" +# sender_name = "DailyOps" +# timezone = "Asia/Taipei" # [[cron.jobs]] # schedule = "0 0 * * 0" diff --git a/crates/openab-core/src/acp/connection.rs b/crates/openab-core/src/acp/connection.rs index 5f5d83747..e278c6110 100644 --- a/crates/openab-core/src/acp/connection.rs +++ b/crates/openab-core/src/acp/connection.rs @@ -602,20 +602,55 @@ impl AcpConnection { Ok(session_id) } - /// Set a config option (e.g. model, mode) via ACP session/set_config_option. - /// Returns the updated list of all config options. + /// Set a config option while retaining the legacy prompt fallback used by + /// operator-supplied default configuration. Broker-owned commands use the + /// strict variant below so they never consume an agent turn. pub async fn set_config_option( &mut self, config_id: &str, value: &str, ) -> Result> { + if let Ok(options) = self.set_config_option_strict(config_id, value).await { + return Ok(options); + } + let session_id = self .acp_session_id .as_ref() .ok_or_else(|| anyhow!("no session"))? .clone(); + let command = format!("/{config_id} {value}"); + info!("set_config_option unsupported; using legacy prompt fallback"); + self.send_request( + "session/prompt", + Some(json!({ + "sessionId": session_id, + "prompt": [{"type": "text", "text": command}], + })), + ) + .await?; + for option in &mut self.config_options { + if option.id == config_id { + option.current_value = value.to_string(); + } + } + Ok(self.config_options.clone()) + } - let resp = self + /// Set a config option only through the ACP configuration method. No + /// `session/prompt` fallback is allowed because command interception must + /// not turn a broker control into an agent turn. + pub async fn set_config_option_strict( + &mut self, + config_id: &str, + value: &str, + ) -> Result> { + let session_id = self + .acp_session_id + .as_ref() + .ok_or_else(|| anyhow!("no session"))? + .clone(); + let response = self .send_request( "session/set_config_option", Some(json!({ @@ -624,39 +659,11 @@ impl AcpConnection { "value": value, })), ) - .await; - - match resp { - Ok(r) => { - if let Some(result) = r.result.as_ref() { - self.config_options = parse_config_options(result); - } - info!(config_id, value, "config option set"); - } - Err(_) => { - // Fall back: send as a slash command (e.g. "/model claude-sonnet-4") - let cmd = format!("/{config_id} {value}"); - info!( - cmd, - "set_config_option not supported, falling back to prompt" - ); - let _resp = self - .send_request( - "session/prompt", - Some(json!({ - "sessionId": session_id, - "prompt": [{"type": "text", "text": cmd}], - })), - ) - .await?; - for opt in &mut self.config_options { - if opt.id == config_id { - opt.current_value = value.to_string(); - } - } - } + .await?; + if let Some(result) = response.result.as_ref() { + self.config_options = parse_config_options(result); } - + info!("config option set"); Ok(self.config_options.clone()) } @@ -956,7 +963,7 @@ mod tests { let (result, inherited) = build_agent_env(&explicit, &inherit); - assert_eq!(result.get(key).unwrap(), "from_config"); + assert_eq!(result.get(key).map(String::as_str), Some("from_config")); assert!(!inherited.contains(&key.to_string())); std::env::remove_var(key); } @@ -970,7 +977,7 @@ mod tests { let (result, inherited) = build_agent_env(&explicit, &inherit); - assert_eq!(result.get(key).unwrap(), "process_value"); + assert_eq!(result.get(key).map(String::as_str), Some("process_value")); assert!(inherited.contains(&key.to_string())); std::env::remove_var(key); } @@ -1022,8 +1029,8 @@ mod reader_loop_tests { )); let stale = b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{\"stopReason\":\"ok\"}}\n"; - agent_stdout_writer.write_all(stale).await.unwrap(); - agent_stdout_writer.flush().await.unwrap(); + assert!(agent_stdout_writer.write_all(stale).await.is_ok()); + assert!(agent_stdout_writer.flush().await.is_ok()); let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv()) .await @@ -1033,7 +1040,7 @@ mod reader_loop_tests { assert!(pending.lock().await.is_empty()); drop(agent_stdout_writer); - handle.await.unwrap(); + assert!(handle.await.is_ok()); } /// Matched-id path: when a response's id is in `pending`, the loop must @@ -1065,8 +1072,8 @@ mod reader_loop_tests { )); let payload = b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"stopReason\":\"end_turn\"}}\n"; - agent_stdout_writer.write_all(payload).await.unwrap(); - agent_stdout_writer.flush().await.unwrap(); + assert!(agent_stdout_writer.write_all(payload).await.is_ok()); + assert!(agent_stdout_writer.flush().await.is_ok()); let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), resp_rx) .await @@ -1082,7 +1089,7 @@ mod reader_loop_tests { assert!(pending.lock().await.is_empty()); drop(agent_stdout_writer); - handle.await.unwrap(); + assert!(handle.await.is_ok()); } #[test] diff --git a/crates/openab-core/src/acp/pool.rs b/crates/openab-core/src/acp/pool.rs index 86b2ee989..f162b6bdf 100644 --- a/crates/openab-core/src/acp/pool.rs +++ b/crates/openab-core/src/acp/pool.rs @@ -393,6 +393,24 @@ impl SessionPool { false } + /// Whether a live in-process ACP connection exists without resuming or + /// creating session state. Control commands use this to avoid turning a + /// read-only query into implicit session activation. + pub async fn has_live_session(&self, thread_id: &str) -> bool { + let connection = { + let state = self.state.read().await; + state.active.get(thread_id).cloned() + }; + let Some(connection) = connection else { + return false; + }; + let live = match connection.try_lock() { + Ok(connection) => connection.alive(), + Err(_) => true, + }; + live + } + pub async fn get_or_create( &self, thread_id: &str, @@ -597,7 +615,7 @@ impl SessionPool { // Apply default config options (e.g. mode=bypass, model=swe-1-6) for (config_id, value) in &self.default_config_options { if let Err(e) = new_conn.set_config_option(config_id, value).await { - warn!(config_id, value, error = %e, "failed to set default config option"); + warn!(error = %e, "failed to set default config option"); } } @@ -769,6 +787,27 @@ impl SessionPool { conn.set_config_option(config_id, value).await } + /// Command-only config mutation. Unlike the compatibility method above, + /// this never falls back to `session/prompt`. + pub async fn set_config_option_strict( + &self, + thread_id: &str, + config_id: &str, + value: &str, + ) -> Result> { + let conn = { + let state = self.state.read().await; + state.active.get(thread_id).cloned().ok_or_else(|| { + anyhow!( + "no connection for thread {}", + crate::redact::redact_session_ids(thread_id) + ) + })? + }; + let mut conn = conn.lock().await; + conn.set_config_option_strict(config_id, value).await + } + /// Query account-level usage/billing from the backend agent for a session /// (kiro-cli extension). Fails when there is no active session for the /// thread or the backend does not support usage queries. @@ -801,12 +840,17 @@ impl SessionPool { "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "sending session/cancel"); + tracing::info!("sending session/cancel"); use tokio::io::AsyncWriteExt; - let mut w = stdin.lock().await; - w.write_all(data.as_bytes()).await?; - w.write_all(b"\n").await?; - w.flush().await?; + tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut writer = stdin.lock().await; + writer.write_all(data.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok::<(), anyhow::Error>(()) + }) + .await + .map_err(|_| anyhow!("session/cancel write timed out"))??; Ok(()) } @@ -827,12 +871,16 @@ impl SessionPool { "method": "session/cancel", "params": {"sessionId": session_id} }))?; - tracing::info!(session_id = %crate::redact::redact_session_ids(&session_id), "reset: sending session/cancel"); + tracing::info!("reset: sending session/cancel"); use tokio::io::AsyncWriteExt; - let mut w = stdin.lock().await; - let _ = w.write_all(data.as_bytes()).await; - let _ = w.write_all(b"\n").await; - let _ = w.flush().await; + let _ = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let mut writer = stdin.lock().await; + writer.write_all(data.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.flush().await?; + Ok::<(), anyhow::Error>(()) + }) + .await; } let mut state = self.state.write().await; @@ -849,7 +897,7 @@ impl SessionPool { self.save_mapping(&state.persisted); self.save_meta(&state.session_workdirs); if had_active { - info!(thread_id = %crate::redact::redact_session_ids(thread_id), "session reset"); + info!("session reset"); Ok(()) } else { Err(anyhow!("no session for thread {}", crate::redact::redact_session_ids(thread_id))) @@ -1040,19 +1088,35 @@ mod tests { #[cfg(feature = "acp-mcp")] impl CountingRegistrar { + fn minted(&self) -> Vec { + self.minted + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + fn revoked(&self) -> Vec { - self.revoked.lock().unwrap().clone() + self.revoked + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() } } #[cfg(feature = "acp-mcp")] impl crate::acp_mcp::SessionTokenRegistrar for CountingRegistrar { fn mint(&self, channel_id: &str) -> String { - self.minted.lock().unwrap().push(channel_id.to_string()); + self.minted + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(channel_id.to_string()); "token-xyz".to_string() } fn revoke(&self, token: &str) { - self.revoked.lock().unwrap().push(token.to_string()); + self.revoked + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(token.to_string()); } } @@ -1072,6 +1136,24 @@ mod tests { } } + #[tokio::test] + async fn persisted_state_is_not_a_live_session_for_read_only_commands() { + let pool = super::SessionPool::new( + crate::config::AgentConfig::default(), + 1, + 900, + HashMap::new(), + ); + pool.state + .write() + .await + .persisted + .insert("teams:persisted-only".into(), "session".into()); + + assert!(pool.has_active_session("teams:persisted-only").await); + assert!(!pool.has_live_session("teams:persisted-only").await); + } + /// F3: replacing a hung predecessor's token revokes the predecessor's EXACT token and leaves /// the successor's standing. Without the revoke the predecessor token keeps resolving to the /// channel and — since `AcpTunnelSource` authorizes by channel — could reach the successor's @@ -1126,7 +1208,9 @@ mod tests { #[cfg(feature = "acp-mcp")] #[tokio::test] async fn no_token_is_minted_when_the_facade_config_write_fails() { - let dir = tempfile::tempdir().unwrap(); + let Ok(dir) = tempfile::tempdir() else { + panic!("temporary directory must be available"); + }; // Make `/.openab` a FILE, so `create_dir_all` inside the writer fails. // // This used to block on `.cursor`, which openab no longer creates: since D-15 it authors @@ -1134,21 +1218,20 @@ mod tests { // `.cursor` the write would SUCCEED, the test would fail, and — worse if it had been // written the other way round — a test asserting "no mint on failure" would have been // passing against a call that never failed. - std::fs::write(dir.path().join(".openab"), b"not a directory").unwrap(); + assert!(std::fs::write(dir.path().join(".openab"), b"not a directory").is_ok()); let counting = Arc::new(CountingRegistrar::default()); let registrar: Arc = counting.clone(); - let token = super::setup_facade_session( - dir.path().to_str().unwrap(), - "http://127.0.0.1:8848/mcp", - "acp_x", - ®istrar, - ) - .await; + let Some(workdir) = dir.path().to_str() else { + panic!("temporary path must be UTF-8"); + }; + let token = + super::setup_facade_session(workdir, "http://127.0.0.1:8848/mcp", "acp_x", ®istrar) + .await; assert!(token.is_none(), "a failed config write must yield no token"); assert!( - counting.minted.lock().unwrap().is_empty(), + counting.minted().is_empty(), "the registrar must never be asked to mint when the config could not be written" ); } @@ -1157,19 +1240,20 @@ mod tests { #[cfg(feature = "acp-mcp")] #[tokio::test] async fn a_successful_facade_config_write_mints_one_token() { - let dir = tempfile::tempdir().unwrap(); + let Ok(dir) = tempfile::tempdir() else { + panic!("temporary directory must be available"); + }; let counting = Arc::new(CountingRegistrar::default()); let registrar: Arc = counting.clone(); - let token = super::setup_facade_session( - dir.path().to_str().unwrap(), - "http://127.0.0.1:8848/mcp", - "acp_x", - ®istrar, - ) - .await; + let Some(workdir) = dir.path().to_str() else { + panic!("temporary path must be UTF-8"); + }; + let token = + super::setup_facade_session(workdir, "http://127.0.0.1:8848/mcp", "acp_x", ®istrar) + .await; assert_eq!(token.as_deref(), Some("token-xyz")); - assert_eq!(counting.minted.lock().unwrap().as_slice(), ["acp_x"]); + assert_eq!(counting.minted(), ["acp_x"]); } #[test] @@ -1294,7 +1378,10 @@ mod tests { struct Cap(StdArc>>); impl Write for Cap { fn write(&mut self, b: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(b); + self.0 + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .extend_from_slice(b); Ok(b.len()) } fn flush(&mut self) -> std::io::Result<()> { @@ -1318,7 +1405,13 @@ mod tests { ); }); - let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + let bytes = buf + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let Ok(out) = String::from_utf8(bytes) else { + panic!("captured tracing output must be UTF-8"); + }; assert!(out.contains("force-evicting hung session"), "the warning must fire: {out}"); assert!(!out.contains(uuid), "no raw uuid may reach the log: {out}"); assert!(!out.contains("acp_") && !out.contains("sess_"), "no raw id prefix either: {out}"); diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index fa7e95dba..4cf86a937 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -1,6 +1,6 @@ use anyhow::Result; use async_trait::async_trait; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::{error, warn}; @@ -9,7 +9,13 @@ use crate::config::{ReactionsConfig, ToolDisplay}; use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::markdown::{self, TableMode}; +use crate::progressive::{ + classify_placeholder, deliver_required_ack_chunks, finalize_edit_after_cosmetic, + finalize_explicit_reply, is_ambiguous_delivery, AmbiguousProgressiveDelivery, + CosmeticEditOutcome, CosmeticEditState, PlaceholderStart, COSMETIC_EDIT_INTERVAL, +}; use crate::reactions::StatusReactionController; +use crate::status::{StatusMessageController, StatusTerminal}; // --- Output directive parsing --- @@ -216,6 +222,14 @@ pub(crate) fn finalize_body( /// Compare with `SenderContext`, which is **metadata for the agent**: there /// `channel_id` is the parent channel and `thread_id` is the thread, /// matching Slack's model for cross-platform consistency. +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PersistentConversationTarget { + pub tenant_id: String, + pub bot_framework_channel_id: String, + pub conversation_id: String, +} + #[derive(Clone, Debug)] pub struct ChannelRef { pub platform: String, @@ -225,6 +239,9 @@ pub struct ChannelRef { pub thread_id: Option, /// Parent channel if this is a thread-as-channel (Discord). pub parent_id: Option, + /// Exact logical identity for a Gateway-local durable conversation route. + /// The stored reference and service URL never enter Core. + pub persistent_conversation: Option>, /// Originating gateway event ID, propagated back in `GatewayReply.reply_to` /// so the gateway can correlate replies with inbound events (e.g. LINE reply tokens). /// Excluded from Hash/Eq — two ChannelRefs pointing to the same channel are @@ -238,6 +255,7 @@ impl PartialEq for ChannelRef { && self.channel_id == other.channel_id && self.thread_id == other.thread_id && self.parent_id == other.parent_id + && self.persistent_conversation == other.persistent_conversation } } @@ -249,6 +267,7 @@ impl std::hash::Hash for ChannelRef { self.channel_id.hash(state); self.thread_id.hash(state); self.parent_id.hash(state); + self.persistent_conversation.hash(state); } } @@ -310,6 +329,198 @@ pub struct SenderContext { pub receiver_id: Option, } +// --- Adapter capability and delivery contracts --- + +/// How an adapter can progressively deliver response content. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamingMode { + /// Send one final message; no placeholder or edit loop. + #[default] + Disabled, + /// Send a placeholder and edit it with complete snapshots. + Edit, + /// Use a platform-native append/finalize streaming API. + Native, +} + +/// Platform message-size budget. Authoritative final content is split in this +/// exact unit; cosmetic previews may use a conservative character projection. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", rename_all = "snake_case")] +pub enum MessageLimit { + Characters { max: usize }, + Bytes { max: usize }, + Utf16Bytes { max: usize }, + Unlimited, +} + +impl Default for MessageLimit { + fn default() -> Self { + Self::Characters { max: 4096 } + } +} + +impl MessageLimit { + pub fn conservative_char_limit(self) -> usize { + match self { + Self::Characters { max } => max.max(1), + Self::Bytes { max } => (max / 4).max(1), + Self::Utf16Bytes { max } => (max / 4).max(1), + Self::Unlimited => usize::MAX, + } + } + + pub(crate) fn text_budget(self) -> format::TextBudget { + match self { + Self::Characters { max } => format::TextBudget::Characters(max), + Self::Bytes { max } => format::TextBudget::Bytes(max), + Self::Utf16Bytes { max } => format::TextBudget::Utf16Bytes(max), + Self::Unlimited => format::TextBudget::Unlimited, + } + } +} + +/// User-visible status mechanism, kept independent from content streaming. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StatusBackend { + #[default] + None, + Reactions, + Assistant, + Typing, + Message, +} + +/// Platform-aware behavior contract used by direct, unified, and standalone +/// gateway adapters. Defaults are deliberately conservative for unknown peers. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdapterCapabilities { + pub send_ack: bool, + pub edit_ack: bool, + pub delete_ack: bool, + /// Whether command targets use the additive `target_message_id` field. + /// False peers require the legacy `reply_to = target` fallback. + pub supports_target_message_id: bool, + /// Native reaction writes are available independently from the selected + /// transient progress backend. Used for permanent batch receipts. + pub supports_reactions: bool, + /// Gateway can resolve one opaque inbound attachment reference after Core + /// trust admission and return bounded normalized bytes. + pub supports_attachment_materialization: bool, + /// Gateway can durably register an authenticated route after Core trust. + pub supports_conversation_registry: bool, + /// Gateway can resolve an exact durable conversation target for proactive writes. + pub supports_persistent_conversation_send: bool, + pub can_edit: bool, + pub can_delete: bool, + pub streaming_mode: StreamingMode, + pub show_streaming_placeholder: bool, + pub message_limit: MessageLimit, + pub status_backend: StatusBackend, +} + +impl Default for AdapterCapabilities { + fn default() -> Self { + Self { + send_ack: false, + edit_ack: false, + delete_ack: false, + supports_target_message_id: false, + supports_reactions: false, + supports_attachment_materialization: false, + supports_conversation_registry: false, + supports_persistent_conversation_send: false, + can_edit: false, + can_delete: false, + streaming_mode: StreamingMode::Disabled, + show_streaming_placeholder: true, + message_limit: MessageLimit::default(), + status_backend: StatusBackend::None, + } + } +} + +/// Result of a platform write. `Unknown` is distinct from rejection because a +/// timed-out POST may have reached the platform and must not be blindly retried. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WriteOutcome { + Delivered { + message_id: Option, + }, + Rejected { + code: String, + message: String, + retry_after_ms: Option, + }, + Unknown { + code: String, + message: String, + }, +} + +/// Preserve a structured platform write outcome through legacy `Result` trait +/// methods. Progressive finalization downcasts this error instead of treating +/// every failure as safe for delete-and-fresh-send recovery. +#[derive(Clone, Debug)] +pub struct WriteFailure { + pub outcome: WriteOutcome, +} + +impl WriteFailure { + pub fn new(outcome: WriteOutcome) -> Self { + Self { outcome } + } +} + +impl std::fmt::Display for WriteFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.outcome { + WriteOutcome::Delivered { .. } => write!(f, "unexpected delivered write failure"), + WriteOutcome::Rejected { code, message, .. } => { + write!(f, "write rejected ({code}): {message}") + } + WriteOutcome::Unknown { code, message } => { + write!(f, "write outcome unknown ({code}): {message}") + } + } + } +} + +impl std::error::Error for WriteFailure {} + +fn failed_write_outcome(operation: &str, error: &anyhow::Error) -> WriteOutcome { + error + .downcast_ref::() + .map(|failure| failure.outcome.clone()) + .unwrap_or_else(|| WriteOutcome::Unknown { + code: format!("{operation}_adapter_error"), + message: error.to_string(), + }) +} + +/// Stable wire discriminator carried by additive GatewayResponse fields. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WriteOutcomeKind { + Delivered, + Rejected, + Unknown, +} + +/// Bounded attachment result returned by an adapter after trust admission. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaterializedAttachment { + pub attachment_type: String, + pub filename: String, + pub mime_type: String, + pub data: Vec, + pub size: u64, + pub status: Option, +} + // --- ChatAdapter trait --- #[async_trait] @@ -322,9 +533,69 @@ pub trait ChatAdapter: Send + Sync + 'static { /// for Discord; Slack uses its Block Kit `markdown` block cap). fn message_limit(&self) -> usize; + /// Platform-aware capability view. Shared adapters override this method to + /// select behavior using `ChannelRef.platform`; direct adapters inherit a + /// backward-compatible view derived from their existing trait methods. + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + let streaming_mode = if self.uses_native_streaming(false) { + StreamingMode::Native + } else if self.use_streaming(false) { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }; + let message_limit = if platform == "acp" { + MessageLimit::Unlimited + } else { + MessageLimit::Characters { + max: self.message_limit(), + } + }; + let status_backend = if self.uses_assistant_status() { + StatusBackend::Assistant + } else { + StatusBackend::Reactions + }; + AdapterCapabilities { + can_edit: streaming_mode != StreamingMode::Disabled, + can_delete: streaming_mode != StreamingMode::Disabled, + streaming_mode, + show_streaming_placeholder: self.show_streaming_placeholder(), + message_limit, + supports_reactions: status_backend == StatusBackend::Reactions, + status_backend, + ..AdapterCapabilities::default() + } + } + + /// Resolve one Gateway-local opaque attachment reference after the caller + /// has completed structural, scope, and identity admission. + async fn materialize_attachment( + &self, + _channel: &ChannelRef, + _reference: &str, + ) -> Result { + Err(anyhow::anyhow!("attachment materialization not supported")) + } + + /// Register a trusted Gateway-local conversation route for later use. + async fn register_conversation(&self, _channel: &ChannelRef) -> Result<()> { + Err(anyhow::anyhow!("conversation registry not supported")) + } + /// Send a new message, returns a reference to the sent message. async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result; + /// Outcome-preserving send used by duplicate-safe progressive delivery. + async fn send_message_outcome(&self, channel: &ChannelRef, content: &str) -> WriteOutcome { + match self.send_message(channel, content).await { + Ok(message) => WriteOutcome::Delivered { + message_id: Some(message.message_id), + }, + Err(error) => failed_write_outcome("send", &error), + } + } + /// Create a thread from a trigger message, returns the thread channel ref. async fn create_thread( &self, @@ -345,6 +616,14 @@ pub trait ChatAdapter: Send + Sync + 'static { Err(anyhow::anyhow!("edit_message not supported")) } + /// Outcome-preserving edit used by authoritative finalization. + async fn edit_message_outcome(&self, msg: &MessageRef, content: &str) -> WriteOutcome { + match self.edit_message(msg, content).await { + Ok(()) => WriteOutcome::Delivered { message_id: None }, + Err(error) => failed_write_outcome("edit", &error), + } + } + /// Send a message as a reply to a specific message (Discord: message_reference). /// Default: falls back to plain send_message (ignores reply_to). async fn send_message_with_reply( @@ -357,6 +636,24 @@ pub trait ChatAdapter: Send + Sync + 'static { self.send_message(channel, content).await } + /// Outcome-preserving explicit reply send. + async fn send_message_with_reply_outcome( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> WriteOutcome { + match self + .send_message_with_reply(channel, content, reply_to_message_id) + .await + { + Ok(message) => WriteOutcome::Delivered { + message_id: Some(message.message_id), + }, + Err(error) => failed_write_outcome("reply_send", &error), + } + } + /// Rename the thread/channel title. Default: no-op (not all platforms support it). async fn rename_thread(&self, _channel: &ChannelRef, _title: &str) -> Result<()> { Ok(()) @@ -368,6 +665,14 @@ pub trait ChatAdapter: Send + Sync + 'static { self.edit_message(msg, "\u{200b}").await } + /// Outcome-preserving delete used by progressive recovery. + async fn delete_message_outcome(&self, msg: &MessageRef) -> WriteOutcome { + match self.delete_message(msg).await { + Ok(()) => WriteOutcome::Delivered { message_id: None }, + Err(error) => failed_write_outcome("delete", &error), + } + } + /// Whether this adapter streams via a native streaming API (Slack /// chat.startStream) rather than the post+edit loop. Default: false. /// `other_bot_present` lets adapters fall back to send-once in multi-bot @@ -466,6 +771,24 @@ pub struct AdapterRouter { trust: crate::trust::PlatformTrustConfigs, } +fn use_structured_progressive( + platform: &str, + streaming: bool, + native: bool, + capabilities: &AdapterCapabilities, +) -> bool { + platform == "teams" + && streaming + && !native + && capabilities.send_ack + && capabilities.edit_ack + && capabilities.delete_ack + && capabilities.supports_target_message_id + && capabilities.can_edit + && capabilities.can_delete + && capabilities.show_streaming_placeholder +} + impl AdapterRouter { pub fn new( pool: Arc, @@ -519,6 +842,17 @@ impl AdapterRouter { self.trust.decide(platform, channel_id, is_dm, sender_id) } + /// Evaluate only L3 identity after an adapter-specific typed-scope policy + /// has already admitted L2. Teams needs this because Team-or-channel scope + /// matching cannot be represented by the legacy flat channel allowlist. + pub fn gate_identity(&self, platform: &str, sender_id: &str) -> crate::trust::Decision { + if self.trust.get(platform).identity_allowed(sender_id) { + crate::trust::Decision::Allow + } else { + crate::trust::Decision::DenyIdentity + } + } + /// Access the underlying session pool (e.g. for config option queries). pub fn pool(&self) -> &Arc { &self.pool @@ -605,10 +939,13 @@ impl AdapterRouter { return Err(e); } - // In assistant-status mode (e.g. Slack assistant_mode), status is conveyed - // via assistant.threads.setStatus, so the emoji-reaction lifecycle is skipped - // entirely — mirrors dispatch_batch so per-message and batched modes agree. - let assistant_status = adapter.uses_assistant_status(); + // Status and content streaming are separate capabilities. Only the + // reactions backend drives the emoji lifecycle here; assistant status is + // handled inside stream_prompt_blocks and `none` remains side-effect free. + let capabilities = adapter.capabilities(&ctx.thread_channel.platform); + let reaction_status = capabilities.status_backend == StatusBackend::Reactions; + let receipt_reactions = + self.reactions_config.enabled && capabilities.supports_reactions; let reactions = Arc::new(StatusReactionController::new( self.reactions_config.enabled, @@ -617,7 +954,7 @@ impl AdapterRouter { self.reactions_config.emojis.clone(), self.reactions_config.timing.clone(), )); - if !assistant_status { + if receipt_reactions { reactions.set_queued().await; } @@ -632,7 +969,7 @@ impl AdapterRouter { ) .await; - if !assistant_status { + if reaction_status { match &result { Ok(()) => reactions.set_done().await, Err(_) => reactions.set_error().await, @@ -653,9 +990,11 @@ impl AdapterRouter { } if let Err(ref e) = result { - let _ = adapter - .send_message(&ctx.thread_channel, &format!("⚠️ {e}")) - .await; + if !is_ambiguous_delivery(e) { + let _ = adapter + .send_message(&ctx.thread_channel, &format!("⚠️ {e}")) + .await; + } } result @@ -700,25 +1039,35 @@ impl AdapterRouter { ) -> Result<()> { let adapter = adapter.clone(); let thread_channel = thread_channel.clone(); - let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit()); - // ACP must not inherit the unified adapter's Telegram streaming flag (wrong - // coupling): it streams append-only `agent_message_chunk` deltas built from the - // post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it - // explicitly by platform rather than by whatever Telegram happens to be set to. - let streaming = if thread_channel.platform == "acp" { - false - } else { - adapter.use_streaming(other_bot_present) - }; + let capabilities = adapter.capabilities(&thread_channel.platform); + let final_message_budget = capabilities.message_limit.text_budget(); + let capability_limit = capabilities.message_limit.conservative_char_limit(); + let message_limit = reply_message_limit(&thread_channel.platform, capability_limit); + // ACP stays append-only and cannot use the post+edit path. For all other + // platforms, the platform-aware capability is authoritative; multi-bot + // participation still disables streaming for the current turn. + let streaming = thread_channel.platform != "acp" + && capabilities.streaming_mode != StreamingMode::Disabled + && !other_bot_present; // Keep the full turn text (incl. inter-tool narration) when streaming // (it was already shown live) OR when `[reactions] narration_display` is // set. Otherwise a send-once turn delivers only the final answer block. // Platform-agnostic — read from the shared reactions config, alongside // `tool_display`. `streaming` still drives the placeholder / native-stream // paths below; only the final-text selection uses `keep_full_text`. - let keep_full_text = streaming || self.reactions_config.narration_display; - let native = adapter.uses_native_streaming(other_bot_present); - let assistant_status = adapter.uses_assistant_status(); + let narration_display = self.reactions_config.narration_display; + let keep_full_text = streaming || narration_display; + let native = streaming && capabilities.streaming_mode == StreamingMode::Native; + let structured_progressive = + use_structured_progressive(&thread_channel.platform, streaming, native, &capabilities); + let assistant_status = capabilities.status_backend == StatusBackend::Assistant; + let reaction_status = capabilities.status_backend == StatusBackend::Reactions; + let message_status_enabled = capabilities.status_backend == StatusBackend::Message; + let message_status = Arc::new(StatusMessageController::new( + message_status_enabled, + adapter.clone(), + thread_channel.clone(), + )); // Platforms that render Markdown tables natively (e.g. Slack Block Kit // `markdown` blocks / `markdown_text` stream chunks) skip the // table→code/bullets pre-pass so the raw table renders natively. @@ -743,9 +1092,11 @@ impl AdapterRouter { conn.session_reset = false; let (mut rx, request_id) = conn.session_prompt(content_blocks).await?; - if assistant_status { + if message_status_enabled { + message_status.set_thinking().await; + } else if assistant_status { let _ = adapter.set_status(&thread_channel, "Thinking…").await; - } else { + } else if reaction_status { reactions.set_thinking().await; } @@ -773,96 +1124,151 @@ impl AdapterRouter { let mut native_last_flush = tokio::time::Instant::now(); const NATIVE_FLUSH_MS: u128 = 400; - // Streaming edit: send placeholder, spawn edit loop - let (buf_tx, placeholder_msg, edit_handle) = if streaming && !native { - let initial = if reset { - "⚠️ _Session expired, starting fresh..._\n\n…".to_string() - } else { - "…".to_string() - }; - let msg = if adapter.show_streaming_placeholder() { - adapter.send_message(&thread_channel, &initial).await? - } else { - // Dummy ref for edit loop — gateway uses drafts, doesn't need real msg_id - MessageRef { - message_id: "draft".to_string(), - channel: thread_channel.clone(), - } - }; - let (tx, rx) = tokio::sync::watch::channel(initial); - let edit_adapter = adapter.clone(); - let edit_msg = msg.clone(); - let limit = message_limit; - let mut buf_rx = rx; - let edit_handle = tokio::spawn(async move { - let mut last = String::new(); - // Track consecutive edit failures so we can abort cosmetic - // streaming when the platform stops accepting edits (e.g. - // Feishu's 20-edits-per-message hard cap, errcode 230072). - // Once aborted, the final delivery path still runs and the - // user sees the complete content at turn end. - let mut consecutive_failures: u32 = 0; - const MAX_CONSECUTIVE_FAILURES: u32 = 3; - loop { - tokio::time::sleep(std::time::Duration::from_millis(1500)).await; - if buf_rx.has_changed().unwrap_or(false) { - let content = buf_rx.borrow_and_update().clone(); - if content != last { - let display = if content.chars().count() > limit - 100 { - format!( - "…{}", - format::truncate_chars_tail(&content, limit - 100) - ) - } else { - content.clone() - }; - match edit_adapter - .edit_message(&edit_msg, &display) - .await - { - Ok(_) => { - consecutive_failures = 0; - last = content; - } - Err(e) => { - consecutive_failures += 1; - tracing::debug!( - message_id = %edit_msg.message_id, - platform = %edit_msg.channel.platform, - error = ?e, - consecutive_failures, - "mid-stream cosmetic edit failed" - ); - if consecutive_failures - >= MAX_CONSECUTIVE_FAILURES - { - tracing::warn!( + // Streaming edit: create one real placeholder when structured + // outcomes are available, then spawn the cosmetic edit loop. + let mut placeholder_create_unknown = false; + let mut placeholder_create_rejected = false; + let (buf_tx, placeholder_msg, edit_handle, cosmetic_edit_state) = + if streaming && !native { + let initial = if reset { + "⚠️ _Session expired, starting fresh..._\n\n…".to_string() + } else { + "…".to_string() + }; + let msg = if capabilities.show_streaming_placeholder { + if structured_progressive { + match classify_placeholder( + &thread_channel, + adapter + .send_message_outcome(&thread_channel, &initial) + .await, + ) { + PlaceholderStart::Ready(message) => Some(message), + PlaceholderStart::Rejected => { + placeholder_create_rejected = true; + None + } + PlaceholderStart::Unknown => { + placeholder_create_unknown = true; + None + } + } + } else { + Some(adapter.send_message(&thread_channel, &initial).await?) + } + } else { + // Dummy ref for edit loop — gateway drafts do not need a real ID. + Some(MessageRef { + message_id: "draft".to_string(), + channel: thread_channel.clone(), + }) + }; + + if let Some(msg) = msg { + let (tx, rx) = tokio::sync::watch::channel(initial); + let edit_adapter = adapter.clone(); + let edit_msg = msg.clone(); + let edit_state = Arc::new(std::sync::Mutex::new( + CosmeticEditState::default(), + )); + let task_edit_state = edit_state.clone(); + let limit = message_limit; + let mut buf_rx = rx; + let edit_handle = tokio::spawn(async move { + // Only newer changed display content can supersede a failed + // PUT. Reserve it as Unknown before awaiting so cancellation + // cannot turn an in-flight write into a duplicate final PUT. + loop { + tokio::time::sleep(COSMETIC_EDIT_INTERVAL).await; + if buf_rx.has_changed().unwrap_or(false) { + let content = buf_rx.borrow_and_update().clone(); + let display = + if content.chars().count() > limit - 100 { + format!( + "…{}", + format::truncate_chars_tail( + &content, + limit - 100, + ) + ) + } else { + content + }; + let should_attempt = { + let mut state = task_edit_state + .lock() + .unwrap_or_else(|poisoned| { + poisoned.into_inner() + }); + state.begin_attempt(display.clone()) + }; + if should_attempt { + let result = edit_adapter + .edit_message(&edit_msg, &display) + .await; + let outcome = match &result { + Ok(()) => CosmeticEditOutcome::Delivered, + Err(error) => match failed_write_outcome( + "edit", + error, + ) { + WriteOutcome::Rejected { .. } => { + CosmeticEditOutcome::Rejected + } + WriteOutcome::Delivered { .. } + | WriteOutcome::Unknown { .. } => { + CosmeticEditOutcome::Unknown + } + }, + }; + let (stop, consecutive_failures) = { + let mut state = task_edit_state + .lock() + .unwrap_or_else(|poisoned| { + poisoned.into_inner() + }); + let stop = state.complete_attempt(outcome); + (stop, state.consecutive_failures()) + }; + if let Err(e) = result { + tracing::debug!( message_id = %edit_msg.message_id, platform = %edit_msg.channel.platform, + error = ?e, consecutive_failures, - "mid-stream cosmetic edit aborted; \ - final content will be delivered at turn end" + "mid-stream cosmetic edit failed" ); - break; + if stop { + tracing::warn!( + message_id = %edit_msg.message_id, + platform = %edit_msg.channel.platform, + consecutive_failures, + "mid-stream cosmetic edit aborted; \ + final content will be delivered at turn end" + ); + break; + } } } } + if buf_rx.has_changed().is_err() { + break; + } } - } - if buf_rx.has_changed().is_err() { - break; - } + }); + (Some(tx), Some(msg), Some(edit_handle), Some(edit_state)) + } else { + (None, None, None, None) } - }); - (Some(tx), Some(msg), Some(edit_handle)) - } else { - (None, None, None) - }; + } else { + (None, None, None, None) + }; // (#732) Liveness-aware recv loop. Filters stale id-bearing // messages and abandons cleanly on dead agent / hard ceiling // so late responses cannot leak into the next prompt. let mut response_error: Option = None; + let mut hard_timed_out = false; let mut turn_result = TurnResult::default(); let prompt_start = tokio::time::Instant::now(); loop { @@ -902,6 +1308,7 @@ impl AdapterRouter { break; } if prompt_start.elapsed() > prompt_hard_timeout { + hard_timed_out = true; response_error = Some(format!( "Agent exceeded hard timeout ({}s)", prompt_hard_timeout.as_secs(), @@ -969,24 +1376,29 @@ impl AdapterRouter { } } AcpEvent::Thinking => { - if assistant_status { + if message_status_enabled { + message_status.set_thinking().await; + } else if assistant_status { let _ = adapter .set_status(&thread_channel, "Thinking…") .await; - } else { + } else if reaction_status { reactions.set_thinking().await; } } AcpEvent::ToolStart { id, title } if !title.is_empty() => { - // Live indicator: assistant status line vs emoji reaction. - if assistant_status { + // Live indicator: processing message, assistant status line, + // or emoji reaction. These are independent from content streaming. + if message_status_enabled { + message_status.set_tool(&title).await; + } else if assistant_status { let _ = adapter .set_status( &thread_channel, &format!("Using {title}…"), ) .await; - } else { + } else if reaction_status { reactions.set_tool(&title).await; } // Record the tool in BOTH modes so the finalized message keeps @@ -1025,12 +1437,15 @@ impl AdapterRouter { // tool; send-once delivery slices from here so the // preceding inter-tool narration is dropped. answer_start = text_buf.len(); - // Live indicator: assistant status line vs emoji reaction. - if assistant_status { + // Live indicator: processing message, assistant status line, + // or emoji reaction. + if message_status_enabled { + message_status.set_thinking().await; + } else if assistant_status { let _ = adapter .set_status(&thread_channel, "Thinking…") .await; - } else { + } else if reaction_status { reactions.set_thinking().await; } // Update the tool's state in BOTH modes (see ToolStart) so the @@ -1089,12 +1504,20 @@ impl AdapterRouter { // and if finalize's PUT travels a different pooled connection the // server-side arrival order is not strictly guaranteed. That // residual window is display-only (stale tail briefly shown) and - // far narrower than before this join existed. + // far narrower than before this join existed. Structured Teams + // also reserves an in-flight display as Unknown before awaiting; + // finalization will not repeat that exact content blindly. drop(buf_tx); if let Some(handle) = edit_handle { handle.abort(); let _ = handle.await; } + let cosmetic_edit_snapshot = cosmetic_edit_state.as_ref().map(|state| { + state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + }); // In send-once mode, deliver only the final answer block — // the text after the last tool call — so inter-tool narration @@ -1104,6 +1527,11 @@ impl AdapterRouter { // FULL buffer (they sit at output start, which the slice may // drop) so a leading [[reply_to:...]] survives the narration // it was emitted alongside. + let keep_full_text = if placeholder_create_rejected { + narration_display + } else { + keep_full_text + }; let (directives, text_buf) = split_delivery(&text_buf, answer_start, keep_full_text); // The session-reset notice lives at the head of the buffer; a @@ -1113,6 +1541,14 @@ impl AdapterRouter { // encodes the four-corner truth table so it can be unit-tested. let text_buf = finalize_body(reset, keep_full_text, answer_start, text_buf); + let status_terminal = if hard_timed_out { + StatusTerminal::TimedOut + } else if response_error.is_some() || turn_result.is_silent_failure() { + StatusTerminal::Failed + } else { + StatusTerminal::Completed + }; + // Build final content let final_content = display_for(platform_is_acp, &tool_lines, &text_buf, false, tool_display); @@ -1141,15 +1577,47 @@ impl AdapterRouter { &final_content, message_limit.saturating_sub(mention_reserve), ); - propagate_mentions_to_chunks(chunks, &mentions, message_limit) + Ok(propagate_mentions_to_chunks( + chunks, + &mentions, + message_limit, + )) } else { - format::split_message(&final_content, message_limit) + format::split_message_with_budget(&final_content, final_message_budget) + .map_err(anyhow::Error::new) + }; + let chunks = match chunks { + Ok(chunks) => chunks, + Err(error) => { + warn!( + platform = %thread_channel.platform, + error = %error, + "final content cannot fit the negotiated message budget" + ); + if message_status_enabled { + message_status + .mark_terminal(StatusTerminal::DeliveryFailed) + .await; + } + if assistant_status { + let _ = adapter.set_status(&thread_channel, "").await; + } + return Err(error.context("reply formatting failed")); + } }; // Track delivery health across all final write paths. Any failure // here means the user's view is incomplete; we propagate Err at the // end of the closure so dispatch surfaces set_error (❌) instead of // silently calling set_done (🆗) over a half-delivered turn. - let mut delivery_failed = false; + let mut delivery_failed = placeholder_create_unknown; + let mut delivery_ambiguous = placeholder_create_unknown; + let mut chunk_failure = None; + // Terminate status before delivering final content. A successful final + // delivery clears the processing message below; a failed delete can + // therefore leave only recognizable terminal text. + if message_status_enabled { + message_status.mark_terminal(status_terminal).await; + } // Clear the assistant status line before delivering the final message. if assistant_status { let _ = adapter.set_status(&thread_channel, "").await; @@ -1213,34 +1681,50 @@ impl AdapterRouter { } } else if let Some(msg) = placeholder_msg { if let Some(ref reply_id) = directives.reply_to { - // reply_to directive: send reply first, then delete placeholder. - // Only delete if send succeeds — preserves placeholder on failure. - let mut send_ok = false; - let mut first = true; - for chunk in &chunks { - if first { - match adapter.send_message_with_reply( - &thread_channel, - chunk, - reply_id, - ).await { - Ok(_) => { send_ok = true; } - Err(e) => { - tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to send failed; preserving placeholder"); - delivery_failed = true; + if structured_progressive { + let health = finalize_explicit_reply( + &adapter, + &thread_channel, + &msg, + reply_id, + &chunks, + ) + .await; + delivery_failed |= health.failed; + delivery_ambiguous |= health.ambiguous; + if health.chunk_failure.is_some() { + chunk_failure = health.chunk_failure; + } + } else { + // reply_to directive: send reply first, then delete placeholder. + // Only delete if send succeeds — preserves placeholder on failure. + let mut send_ok = false; + let mut first = true; + for chunk in &chunks { + if first { + match adapter.send_message_with_reply( + &thread_channel, + chunk, + reply_id, + ).await { + Ok(_) => { send_ok = true; } + Err(e) => { + tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to send failed; preserving placeholder"); + delivery_failed = true; + } } + } else if let Err(e) = + adapter.send_message(&thread_channel, chunk).await + { + tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to overflow chunk send failed"); + delivery_failed = true; } - } else if let Err(e) = - adapter.send_message(&thread_channel, chunk).await - { - tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "reply_to overflow chunk send failed"); - delivery_failed = true; + first = false; } - first = false; - } - if send_ok { - if let Err(e) = adapter.delete_message(&msg).await { - tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "delete placeholder failed; placeholder will remain visible"); + if send_ok { + if let Err(e) = adapter.delete_message(&msg).await { + tracing::warn!(error = ?e, platform = %thread_channel.platform, message_id = %msg.message_id, "delete placeholder failed; placeholder will remain visible"); + } } } } else if adapter.platform() == "discord" @@ -1271,6 +1755,20 @@ impl AdapterRouter { if send_ok { let _ = adapter.delete_message(&msg).await; } + } else if structured_progressive { + let health = finalize_edit_after_cosmetic( + &adapter, + &thread_channel, + &msg, + &chunks, + cosmetic_edit_snapshot.as_ref(), + ) + .await; + delivery_failed |= health.failed; + delivery_ambiguous |= health.ambiguous; + if health.chunk_failure.is_some() { + chunk_failure = health.chunk_failure; + } } else { // Normal streaming: edit first chunk into placeholder, send rest. // If placeholder is a dummy "draft" ref (no real message), send as @@ -1316,9 +1814,41 @@ impl AdapterRouter { } } } + } else if placeholder_create_unknown { + // The placeholder POST may have committed without returning its + // real activity ID. Do not create any additional Teams activity. + } else if structured_progressive && placeholder_create_rejected { + let health = deliver_required_ack_chunks( + &adapter, + &thread_channel, + directives.reply_to.as_deref(), + &chunks, + ) + .await; + delivery_failed |= health.failed; + delivery_ambiguous |= health.ambiguous; + if health.chunk_failure.is_some() { + chunk_failure = health.chunk_failure; + } + } else if capabilities.send_ack { + // A negotiated required send ACK makes each chunk outcome + // authoritative. Deliver sequentially and stop at the first + // rejected or unknown POST so no suffix can skip a gap. + let health = deliver_required_ack_chunks( + &adapter, + &thread_channel, + directives.reply_to.as_deref(), + &chunks, + ) + .await; + delivery_failed |= health.failed; + delivery_ambiguous |= health.ambiguous; + if health.chunk_failure.is_some() { + chunk_failure = health.chunk_failure; + } } else { - // Send-once: all chunks as new messages - // First chunk uses reply_to directive if present + // Legacy peers preserve best-effort send-once behavior. New + // required-ACK peers use the ordered branch above. let mut first = true; for chunk in &chunks { if first { @@ -1347,11 +1877,49 @@ impl AdapterRouter { } } + if let Some(failure) = &chunk_failure { + warn!( + platform = %thread_channel.platform, + delivered_chunks = failure.delivered_chunks, + total_chunks = failure.total_chunks, + failed_chunk_index = failure.failed_chunk_index, + error_code = %failure.error_code, + ambiguous = delivery_ambiguous, + "final chunk delivery stopped before completion" + ); + } + if delivery_failed { - Err(anyhow::anyhow!( - "streaming finalization had delivery failures; user view is incomplete" - )) + if message_status_enabled { + message_status + .mark_terminal(StatusTerminal::DeliveryFailed) + .await; + } + if delivery_ambiguous { + Err(AmbiguousProgressiveDelivery.into()) + } else if let Some(failure) = chunk_failure { + let classification = if failure.delivered_chunks > 0 { + "partial delivery" + } else { + "delivery failed" + }; + Err(anyhow::anyhow!( + "{}: delivered {} of {} chunks; stopped at chunk {} ({})", + classification, + failure.delivered_chunks, + failure.total_chunks, + failure.failed_chunk_index, + failure.error_code, + )) + } else { + Err(anyhow::anyhow!( + "finalization had delivery failures; user view is incomplete" + )) + } } else { + if message_status_enabled { + message_status.clear().await; + } Ok(()) } }) @@ -1746,6 +2314,133 @@ mod tests { assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1); } + #[test] + fn capability_message_limits_are_authoritative_and_conservative() { + assert_eq!( + MessageLimit::Characters { max: 8_000 }.conservative_char_limit(), + 8_000 + ); + assert_eq!( + MessageLimit::Bytes { max: 4_000 }.conservative_char_limit(), + 1_000 + ); + assert_eq!( + MessageLimit::Utf16Bytes { max: 4_000 }.conservative_char_limit(), + 1_000 + ); + assert_eq!( + MessageLimit::Unlimited.conservative_char_limit(), + usize::MAX + ); + assert_eq!( + MessageLimit::Characters { max: 0 }.conservative_char_limit(), + 1 + ); + assert_eq!( + MessageLimit::Utf16Bytes { max: 80_000 } + .text_budget() + .measure("A🙂"), + 6 + ); + assert_eq!( + MessageLimit::Bytes { max: 80_000 } + .text_budget() + .measure("A🙂"), + 5 + ); + assert_eq!( + AdapterCapabilities::default().status_backend, + StatusBackend::None + ); + } + + #[test] + fn teams_table_fallback_precedes_utf16_budgeting() -> Result<()> { + let markdown = "Before\n\n| Name | Value |\n| --- | --- |\n| alpha | 🙂🙂🙂🙂🙂 |\n| beta | 你好世界 |\n\nAfter"; + let rendered = crate::markdown::convert_tables(markdown, TableMode::Code); + assert!(rendered.contains("```\n")); + assert_eq!( + crate::markdown::convert_tables(markdown, TableMode::Off), + markdown + ); + + let budget = MessageLimit::Utf16Bytes { max: 96 }.text_budget(); + let chunks = crate::format::split_message_with_budget(&rendered, budget)?; + assert!(chunks.len() > 1); + for chunk in chunks { + assert!(budget.measure(&chunk) <= 96); + let fences = chunk.lines().filter(|line| line.starts_with("```")).count(); + assert!( + fences.is_multiple_of(2), + "unbalanced table fallback: {chunk:?}" + ); + } + Ok(()) + } + + #[test] + fn structured_progressive_is_teams_only_and_requires_every_primitive() { + let complete = AdapterCapabilities { + send_ack: true, + edit_ack: true, + delete_ack: true, + supports_target_message_id: true, + can_edit: true, + can_delete: true, + show_streaming_placeholder: true, + ..AdapterCapabilities::default() + }; + assert!(use_structured_progressive("teams", true, false, &complete)); + assert!(!use_structured_progressive( + "feishu", true, false, &complete + )); + assert!(!use_structured_progressive( + "teams", false, false, &complete + )); + assert!(!use_structured_progressive("teams", true, true, &complete)); + + for missing in 0..7 { + let mut capabilities = complete.clone(); + match missing { + 0 => capabilities.send_ack = false, + 1 => capabilities.edit_ack = false, + 2 => capabilities.delete_ack = false, + 3 => capabilities.supports_target_message_id = false, + 4 => capabilities.can_edit = false, + 5 => capabilities.can_delete = false, + _ => capabilities.show_streaming_placeholder = false, + } + assert!(!use_structured_progressive( + "teams", + true, + false, + &capabilities + )); + } + } + + #[test] + fn typed_write_failures_survive_legacy_result_methods() { + let rejected = anyhow::Error::new(WriteFailure::new(WriteOutcome::Rejected { + code: "explicit_rejection".into(), + message: "not applied".into(), + retry_after_ms: Some(250), + })); + assert!(matches!( + failed_write_outcome("edit", &rejected), + WriteOutcome::Rejected { + retry_after_ms: Some(250), + .. + } + )); + + let generic = anyhow::anyhow!("transport failed"); + assert!(matches!( + failed_write_outcome("edit", &generic), + WriteOutcome::Unknown { code, .. } if code == "edit_adapter_error" + )); + } + #[test] fn select_delivery_text_send_once_keeps_only_final_block() { // Simulates: narration "n1" → tool (answer_start→2) → narration "n2" @@ -1940,6 +2635,7 @@ mod tests { channel_id: "U123".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt_aaa".into()), }; let b = ChannelRef { @@ -1947,6 +2643,7 @@ mod tests { channel_id: "U123".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt_bbb".into()), }; assert_eq!(a, b, "same channel with different event IDs must be equal"); @@ -1960,6 +2657,7 @@ mod tests { channel_id: "U123".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt_aaa".into()), }; let b = ChannelRef { @@ -1967,6 +2665,7 @@ mod tests { channel_id: "U123".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt_bbb".into()), }; let mut map = HashMap::new(); @@ -1977,6 +2676,35 @@ mod tests { assert_eq!(map.values().next(), Some(&"second")); } + #[test] + fn persistent_conversation_participates_in_routing_equality_and_hash() { + use std::collections::HashSet; + + let channel = |tenant: &str| ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: Some(Box::new(PersistentConversationTarget { + tenant_id: tenant.into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation-1".into(), + })), + origin_event_id: None, + }; + let first = channel("tenant-1"); + let second = channel("tenant-2"); + assert_ne!(first, second); + let mut routes = HashSet::new(); + routes.insert(first.clone()); + routes.insert(second); + assert_eq!(routes.len(), 2); + assert_eq!( + first.clone().persistent_conversation.unwrap().tenant_id, + "tenant-1" + ); + } + #[test] fn origin_event_id_survives_clone() { let ch = ChannelRef { @@ -1984,6 +2712,7 @@ mod tests { channel_id: "U123".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt_abc".into()), }; // Simulates create_thread propagation: clone preserves origin_event_id diff --git a/crates/openab-core/src/commands.rs b/crates/openab-core/src/commands.rs new file mode 100644 index 000000000..96ffbf8a2 --- /dev/null +++ b/crates/openab-core/src/commands.rs @@ -0,0 +1,1116 @@ +//! Platform-neutral command parsing and execution. +//! +//! Ingress admission and presentation remain platform responsibilities. Callers +//! must invoke this service only after their structural, scope, and identity +//! gates have admitted the event. + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::acp::protocol::{ConfigOption, UsageReport}; +use crate::acp::SessionPool; +use crate::dispatch::Dispatcher; + +const COMMAND_EXECUTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); +const TEXT_OPTION_LIMIT: usize = 25; +const TEXT_RESPONSE_CHAR_LIMIT: usize = 3_500; +const TEXT_VALUE_CHAR_LIMIT: usize = 120; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigCategory { + Model, + Agent, +} + +impl ConfigCategory { + pub fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + Self::Agent => "agent", + } + } + + fn matches(self, category: Option<&str>) -> bool { + match self { + Self::Model => category == Some("model"), + Self::Agent => matches!(category, Some("agent" | "mode")), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandName { + Models, + Agents, + Cancel, + CancelAll, + Reset, + Usage, +} + +impl CommandName { + pub fn as_str(self) -> &'static str { + match self { + Self::Models => "models", + Self::Agents => "agents", + Self::Cancel => "cancel", + Self::CancelAll => "cancel-all", + Self::Reset => "reset", + Self::Usage => "usage", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Command { + ListConfig(ConfigCategory), + SetConfig { + category: ConfigCategory, + selector: String, + }, + Cancel, + CancelAll, + Reset, + Usage, + InvalidArguments { + name: CommandName, + }, +} + +impl Command { + pub fn name(&self) -> CommandName { + match self { + Self::ListConfig(ConfigCategory::Model) + | Self::SetConfig { + category: ConfigCategory::Model, + .. + } => CommandName::Models, + Self::ListConfig(ConfigCategory::Agent) + | Self::SetConfig { + category: ConfigCategory::Agent, + .. + } => CommandName::Agents, + Self::Cancel => CommandName::Cancel, + Self::CancelAll => CommandName::CancelAll, + Self::Reset => CommandName::Reset, + Self::Usage => CommandName::Usage, + Self::InvalidArguments { name } => *name, + } + } +} + +/// Parse only broker-owned commands. Prefix collisions and unknown slash text +/// return `None` so agent-native commands continue through the ordinary prompt +/// path. +pub fn parse_command(input: &str) -> Option { + let trimmed = input.trim(); + match trimmed { + "/models" => return Some(Command::ListConfig(ConfigCategory::Model)), + "/agents" => return Some(Command::ListConfig(ConfigCategory::Agent)), + "/cancel" => return Some(Command::Cancel), + "/cancel-all" => return Some(Command::CancelAll), + "/reset" => return Some(Command::Reset), + "/usage" => return Some(Command::Usage), + "/model" => return Some(Command::ListConfig(ConfigCategory::Model)), + "/agent" => return Some(Command::ListConfig(ConfigCategory::Agent)), + _ => {} + } + + for (prefix, name) in [ + ("/models", CommandName::Models), + ("/agents", CommandName::Agents), + ("/cancel-all", CommandName::CancelAll), + ("/cancel", CommandName::Cancel), + ("/reset", CommandName::Reset), + ("/usage", CommandName::Usage), + ] { + if has_whitespace_suffix(trimmed, prefix) { + return Some(Command::InvalidArguments { name }); + } + } + + parse_config_compatibility(trimmed, "/model", ConfigCategory::Model) + .or_else(|| parse_config_compatibility(trimmed, "/agent", ConfigCategory::Agent)) +} + +fn has_whitespace_suffix(input: &str, prefix: &str) -> bool { + input + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.chars().next().is_some_and(char::is_whitespace)) +} + +fn parse_config_compatibility( + input: &str, + prefix: &str, + category: ConfigCategory, +) -> Option { + let suffix = input.strip_prefix(prefix)?; + if suffix.is_empty() { + return Some(Command::ListConfig(category)); + } + if !suffix.chars().next().is_some_and(char::is_whitespace) { + return None; + } + + let mut parts = suffix.split_whitespace(); + match parts.next() { + Some("list") if parts.next().is_none() => Some(Command::ListConfig(category)), + Some("set") => { + let selector = parts.collect::>().join(" "); + if selector.is_empty() { + Some(Command::InvalidArguments { + name: command_name(category), + }) + } else { + Some(Command::SetConfig { category, selector }) + } + } + _ => Some(Command::InvalidArguments { + name: command_name(category), + }), + } +} + +fn command_name(category: ConfigCategory) -> CommandName { + match category { + ConfigCategory::Model => CommandName::Models, + ConfigCategory::Agent => CommandName::Agents, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandContext { + pub platform: String, + pub logical_thread_id: String, + pub response_is_private: bool, +} + +impl CommandContext { + pub fn new( + platform: impl Into, + logical_thread_id: impl Into, + response_is_private: bool, + ) -> Self { + Self { + platform: platform.into(), + logical_thread_id: logical_thread_id.into(), + response_is_private, + } + } + + pub fn session_key(&self) -> String { + format!("{}:{}", self.platform, self.logical_thread_id) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandError { + InvalidArguments(CommandName), + NoConfigOptions(ConfigCategory), + InvalidConfigSelection(Option), + ConfigUpdateUnavailable, + OperationUnavailable, + NoActiveSession, + UsagePrivateOnly, + UsageUnsupported, + UsageUnavailable, +} + +#[derive(Clone, Debug)] +pub enum CommandResult { + ConfigOptions { + category: ConfigCategory, + options: Vec, + }, + ConfigUpdated { + display_name: String, + }, + Cancel { + signalled: bool, + }, + CancelAll { + signalled: bool, + buffers_cleared: bool, + }, + Reset { + session_reset: bool, + buffers_cleared: bool, + }, + Usage(UsageReport), + Error(CommandError), +} + +impl CommandResult { + pub fn outcome_class(&self) -> &'static str { + match self { + Self::ConfigOptions { .. } + | Self::ConfigUpdated { .. } + | Self::Cancel { signalled: true } + | Self::CancelAll { + signalled: true, .. + } + | Self::CancelAll { + buffers_cleared: true, + .. + } + | Self::Reset { + session_reset: true, + .. + } + | Self::Reset { + buffers_cleared: true, + .. + } + | Self::Usage(_) => "completed", + Self::Cancel { signalled: false } + | Self::CancelAll { + signalled: false, + buffers_cleared: false, + } + | Self::Reset { + session_reset: false, + buffers_cleared: false, + } => "no_active_session", + Self::Error(CommandError::UsagePrivateOnly) => "denied_private_surface", + Self::Error(CommandError::InvalidArguments(_)) + | Self::Error(CommandError::InvalidConfigSelection(_)) => "invalid", + Self::Error(_) => "unavailable", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum UsageFailure { + Unsupported, + Unavailable, +} + +#[async_trait] +trait CommandBackend: Send + Sync { + async fn has_live_session(&self, session_key: &str) -> bool; + async fn get_config_options(&self, session_key: &str) -> Vec; + async fn set_config_option( + &self, + session_key: &str, + config_id: &str, + value: &str, + ) -> anyhow::Result<()>; + async fn get_usage(&self, session_key: &str) -> Result; + async fn cancel_session(&self, session_key: &str) -> bool; + async fn reset_session(&self, session_key: &str) -> bool; + fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool; +} + +struct CoreCommandBackend { + pool: Arc, + dispatcher: Arc, +} + +#[async_trait] +impl CommandBackend for CoreCommandBackend { + async fn has_live_session(&self, session_key: &str) -> bool { + self.pool.has_live_session(session_key).await + } + + async fn get_config_options(&self, session_key: &str) -> Vec { + self.pool.get_config_options(session_key).await + } + + async fn set_config_option( + &self, + session_key: &str, + config_id: &str, + value: &str, + ) -> anyhow::Result<()> { + self.pool + .set_config_option_strict(session_key, config_id, value) + .await + .map(|_| ()) + } + + async fn get_usage(&self, session_key: &str) -> Result { + self.pool.get_usage(session_key).await.map_err(|error| { + if error.to_string().contains("usage query is not supported") { + UsageFailure::Unsupported + } else { + UsageFailure::Unavailable + } + }) + } + + async fn cancel_session(&self, session_key: &str) -> bool { + self.pool.cancel_session(session_key).await.is_ok() + } + + async fn reset_session(&self, session_key: &str) -> bool { + self.pool.reset_session(session_key).await.is_ok() + } + + fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool { + self.dispatcher + .cancel_buffered_thread(platform, logical_thread_id) + > 0 + } +} + +#[derive(Clone)] +pub struct CommandService { + backend: Arc, +} + +impl CommandService { + pub fn new(pool: Arc, dispatcher: Arc) -> Self { + Self { + backend: Arc::new(CoreCommandBackend { pool, dispatcher }), + } + } + + pub async fn execute(&self, command: Command, context: &CommandContext) -> CommandResult { + tokio::time::timeout( + COMMAND_EXECUTION_TIMEOUT, + self.execute_inner(command, context), + ) + .await + .unwrap_or(CommandResult::Error(CommandError::OperationUnavailable)) + } + + async fn execute_inner(&self, command: Command, context: &CommandContext) -> CommandResult { + match command { + Command::ListConfig(category) => self.list_config(context, category).await, + Command::SetConfig { category, selector } => { + self.set_config_by_selector(context, category, &selector) + .await + } + Command::Cancel => CommandResult::Cancel { + signalled: self.backend.cancel_session(&context.session_key()).await, + }, + Command::CancelAll => { + let buffers_cleared = self + .backend + .clear_buffered_thread(&context.platform, &context.logical_thread_id); + let signalled = self.backend.cancel_session(&context.session_key()).await; + CommandResult::CancelAll { + signalled, + buffers_cleared, + } + } + Command::Reset => { + let buffers_cleared = self + .backend + .clear_buffered_thread(&context.platform, &context.logical_thread_id); + let session_reset = self.backend.reset_session(&context.session_key()).await; + CommandResult::Reset { + session_reset, + buffers_cleared, + } + } + Command::Usage => self.usage(context).await, + Command::InvalidArguments { name } => { + CommandResult::Error(CommandError::InvalidArguments(name)) + } + } + } + + pub async fn set_config_value( + &self, + context: &CommandContext, + config_id: &str, + value: &str, + ) -> CommandResult { + tokio::time::timeout( + COMMAND_EXECUTION_TIMEOUT, + self.set_config_value_inner(context, config_id, value), + ) + .await + .unwrap_or(CommandResult::Error(CommandError::OperationUnavailable)) + } + + async fn set_config_value_inner( + &self, + context: &CommandContext, + config_id: &str, + value: &str, + ) -> CommandResult { + let options = self + .backend + .get_config_options(&context.session_key()) + .await; + let Some(display_name) = options.iter().find_map(|option| { + if option.id != config_id + || (!ConfigCategory::Model.matches(option.category.as_deref()) + && !ConfigCategory::Agent.matches(option.category.as_deref())) + { + return None; + } + option + .options + .iter() + .find(|choice| choice.value == value) + .map(|choice| choice.name.clone()) + }) else { + return CommandResult::Error(CommandError::InvalidConfigSelection(None)); + }; + + match self + .backend + .set_config_option(&context.session_key(), config_id, value) + .await + { + Ok(()) => CommandResult::ConfigUpdated { display_name }, + Err(_) => CommandResult::Error(CommandError::ConfigUpdateUnavailable), + } + } + + async fn list_config( + &self, + context: &CommandContext, + category: ConfigCategory, + ) -> CommandResult { + let options = matching_options( + self.backend + .get_config_options(&context.session_key()) + .await, + category, + ); + if options.is_empty() { + CommandResult::Error(CommandError::NoConfigOptions(category)) + } else { + CommandResult::ConfigOptions { category, options } + } + } + + async fn set_config_by_selector( + &self, + context: &CommandContext, + category: ConfigCategory, + selector: &str, + ) -> CommandResult { + let options = matching_options( + self.backend + .get_config_options(&context.session_key()) + .await, + category, + ); + if options.is_empty() { + return CommandResult::Error(CommandError::NoConfigOptions(category)); + } + + let choices = ordered_choices(&options); + let selected = selector + .parse::() + .ok() + .and_then(|index| index.checked_sub(1)) + .and_then(|index| choices.get(index).copied()) + .or_else(|| { + let folded = selector.to_lowercase(); + choices.iter().copied().find(|(_, choice)| { + choice.value.to_lowercase() == folded || choice.name.to_lowercase() == folded + }) + }); + let Some((config_id, choice)) = selected else { + return CommandResult::Error(CommandError::InvalidConfigSelection(Some(category))); + }; + + match self + .backend + .set_config_option(&context.session_key(), config_id, &choice.value) + .await + { + Ok(()) => CommandResult::ConfigUpdated { + display_name: choice.name.clone(), + }, + Err(_) => CommandResult::Error(CommandError::ConfigUpdateUnavailable), + } + } + + async fn usage(&self, context: &CommandContext) -> CommandResult { + if !context.response_is_private { + return CommandResult::Error(CommandError::UsagePrivateOnly); + } + if !self.backend.has_live_session(&context.session_key()).await { + return CommandResult::Error(CommandError::NoActiveSession); + } + match self.backend.get_usage(&context.session_key()).await { + Ok(report) => CommandResult::Usage(report), + Err(UsageFailure::Unsupported) => CommandResult::Error(CommandError::UsageUnsupported), + Err(UsageFailure::Unavailable) => CommandResult::Error(CommandError::UsageUnavailable), + } + } +} + +fn matching_options(options: Vec, category: ConfigCategory) -> Vec { + options + .into_iter() + .filter(|option| category.matches(option.category.as_deref())) + .map(|mut option| { + option.category = Some(category.as_str().to_string()); + option + }) + .collect() +} + +fn ordered_choices( + options: &[ConfigOption], +) -> Vec<(&str, &crate::acp::protocol::ConfigOptionValue)> { + let mut choices = Vec::new(); + for option in options { + choices.extend( + option + .options + .iter() + .filter(|choice| choice.value == option.current_value) + .map(|choice| (option.id.as_str(), choice)), + ); + choices.extend( + option + .options + .iter() + .filter(|choice| choice.value != option.current_value) + .map(|choice| (option.id.as_str(), choice)), + ); + } + choices +} + +pub fn render_text_result(result: &CommandResult) -> String { + let text = match result { + CommandResult::ConfigOptions { category, options } => { + let choices = ordered_choices(options); + let shown = choices.len().min(TEXT_OPTION_LIMIT); + let mut lines = vec![format!("🔧 Available {}s:", category.as_str())]; + for (index, (_, choice)) in choices.iter().take(shown).enumerate() { + let is_current = options.iter().any(|option| { + option.current_value == choice.value + && option + .options + .iter() + .any(|candidate| std::ptr::eq(candidate, *choice)) + }); + lines.push(format!( + " {}. {}{}", + index + 1, + truncate_chars(&choice.name, TEXT_VALUE_CHAR_LIMIT), + if is_current { " ✅" } else { "" } + )); + } + if choices.len() > shown { + lines.push(format!( + "… {} more option(s) omitted.", + choices.len() - shown + )); + } + lines.push(format!( + "\nUsage: /{} set ", + category.as_str() + )); + lines.join("\n") + } + CommandResult::ConfigUpdated { display_name } => format!( + "✅ Switched to **{}**", + truncate_chars(display_name, TEXT_VALUE_CHAR_LIMIT) + ), + CommandResult::Cancel { signalled: true } => "🛑 Cancel signal sent.".to_string(), + CommandResult::Cancel { signalled: false } => { + "⚠️ Nothing to cancel — no active session.".to_string() + } + CommandResult::CancelAll { + signalled: true, + buffers_cleared: true, + } => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(), + CommandResult::CancelAll { + signalled: true, + buffers_cleared: false, + } => "🛑 Cancel signal sent.".to_string(), + CommandResult::CancelAll { + signalled: false, + buffers_cleared: true, + } => "🛑 Buffered messages cleared. No active session to cancel.".to_string(), + CommandResult::CancelAll { + signalled: false, + buffers_cleared: false, + } => "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string(), + CommandResult::Reset { + session_reset: true, + buffers_cleared: true, + } => "🔄 Session reset. Buffered messages cleared. Start a new conversation!".to_string(), + CommandResult::Reset { + session_reset: true, + buffers_cleared: false, + } => "🔄 Session reset. Start a new conversation!".to_string(), + CommandResult::Reset { + session_reset: false, + buffers_cleared: true, + } => "🔄 Buffered messages cleared. No active session to reset.".to_string(), + CommandResult::Reset { + session_reset: false, + buffers_cleared: false, + } => "⚠️ No active session to reset.".to_string(), + CommandResult::Usage(report) => render_usage(report), + CommandResult::Error(error) => render_error(*error), + }; + truncate_chars(&text, TEXT_RESPONSE_CHAR_LIMIT) +} + +fn render_usage(report: &UsageReport) -> String { + let mut lines = vec![format!( + "📊 **Usage — {}**", + truncate_chars(&report.plan_name, TEXT_VALUE_CHAR_LIMIT) + )]; + for breakdown in &report.breakdowns { + let name = truncate_chars(&breakdown.display_name, TEXT_VALUE_CHAR_LIMIT); + match breakdown.limit { + Some(limit) => { + let percentage = breakdown.percentage.unwrap_or_else(|| { + if limit > 0.0 { + (breakdown.used / limit * 100.0).round() as u64 + } else { + 0 + } + }); + let filled = percentage.min(100) as usize / 10; + let bar = "█".repeat(filled) + &"░".repeat(10 - filled); + lines.push(format!( + "{name}: {:.2} / {:.0} `{bar}` {percentage}%{}", + breakdown.used, + limit, + if percentage > 100 { " ⚠️" } else { "" } + )); + } + None => lines.push(format!("{name}: {:.2} used", breakdown.used)), + } + if let Some(charges) = breakdown.overage_charges.filter(|charges| *charges > 0.0) { + lines.push(format!( + "Overage charges: {:.2} {}", + charges, + truncate_chars( + breakdown.currency.as_deref().unwrap_or("USD"), + TEXT_VALUE_CHAR_LIMIT, + ) + )); + } + } + if let Some(reset) = &report.billing_cycle_reset { + lines.push(format!( + "Billing cycle resets {}", + truncate_chars(reset, TEXT_VALUE_CHAR_LIMIT) + )); + } + lines.join("\n") +} + +fn render_error(error: CommandError) -> String { + match error { + CommandError::InvalidArguments(name) => format!( + "⚠️ Invalid arguments. Usage: {}", + match name { + CommandName::Models => "/models or /model list | /model set ", + CommandName::Agents => "/agents or /agent list | /agent set ", + CommandName::Cancel => "/cancel", + CommandName::CancelAll => "/cancel-all", + CommandName::Reset => "/reset", + CommandName::Usage => "/usage", + } + ), + CommandError::NoConfigOptions(category) => format!( + "⚠️ No {} options available. Start a conversation first.", + category.as_str() + ), + CommandError::InvalidConfigSelection(Some(category)) => format!( + "⚠️ No matching {}. Use /{} list to see options.", + category.as_str(), + category.as_str() + ), + CommandError::InvalidConfigSelection(None) => { + "⚠️ That configuration selection is no longer available.".to_string() + } + CommandError::ConfigUpdateUnavailable => { + "❌ The configuration change could not be completed.".to_string() + } + CommandError::OperationUnavailable => "⚠️ The command could not be completed.".to_string(), + CommandError::NoActiveSession => { + "⚠️ No active session. Start a conversation first.".to_string() + } + CommandError::UsagePrivateOnly => { + "🔒 `/usage` is only available in a private chat.".to_string() + } + CommandError::UsageUnsupported => { + "⚠️ Usage reporting is not supported by this backend.".to_string() + } + CommandError::UsageUnavailable => { + "⚠️ Usage information is temporarily unavailable.".to_string() + } + } +} + +fn truncate_chars(input: &str, max: usize) -> String { + if input.chars().count() <= max { + input.to_string() + } else if max == 0 { + String::new() + } else { + let mut output: String = input.chars().take(max - 1).collect(); + output.push('…'); + output + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::acp::protocol::{ConfigOptionValue, UsageBreakdown}; + + #[derive(Default)] + struct FakeState { + active: bool, + options: Vec, + usage: Option>, + usage_delay: Option, + cancel_succeeds: bool, + reset_succeeds: bool, + buffers_cleared: bool, + set_calls: Vec<(String, String, String)>, + usage_calls: usize, + cancel_calls: usize, + reset_calls: usize, + clear_calls: Vec<(String, String)>, + } + + #[derive(Default)] + struct FakeBackend { + state: Mutex, + } + + impl FakeBackend { + fn state(&self) -> std::sync::MutexGuard<'_, FakeState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + } + + #[async_trait] + impl CommandBackend for FakeBackend { + async fn has_live_session(&self, _session_key: &str) -> bool { + self.state().active + } + + async fn get_config_options(&self, _session_key: &str) -> Vec { + self.state().options.clone() + } + + async fn set_config_option( + &self, + session_key: &str, + config_id: &str, + value: &str, + ) -> anyhow::Result<()> { + self.state().set_calls.push(( + session_key.to_string(), + config_id.to_string(), + value.to_string(), + )); + Ok(()) + } + + async fn get_usage(&self, _session_key: &str) -> Result { + let (delay, result) = { + let mut state = self.state(); + state.usage_calls += 1; + ( + state.usage_delay, + state + .usage + .clone() + .unwrap_or(Err(UsageFailure::Unavailable)), + ) + }; + if let Some(delay) = delay { + tokio::time::sleep(delay).await; + } + result + } + + async fn cancel_session(&self, _session_key: &str) -> bool { + let mut state = self.state(); + state.cancel_calls += 1; + state.cancel_succeeds + } + + async fn reset_session(&self, _session_key: &str) -> bool { + let mut state = self.state(); + state.reset_calls += 1; + state.reset_succeeds + } + + fn clear_buffered_thread(&self, platform: &str, logical_thread_id: &str) -> bool { + let mut state = self.state(); + state + .clear_calls + .push((platform.to_string(), logical_thread_id.to_string())); + state.buffers_cleared + } + } + + fn service(backend: Arc) -> CommandService { + CommandService { backend } + } + + fn context(private: bool) -> CommandContext { + CommandContext::new("teams", "conversation", private) + } + + fn option(category: &str, count: usize, current: usize) -> ConfigOption { + ConfigOption { + id: category.to_string(), + name: category.to_string(), + description: None, + category: Some(category.to_string()), + option_type: "enum".to_string(), + current_value: format!("value-{current}"), + options: (0..count) + .map(|index| ConfigOptionValue { + value: format!("value-{index}"), + name: format!("Choice {index}"), + description: None, + }) + .collect(), + } + } + + fn usage_report(limit: Option, percentage: Option) -> UsageReport { + UsageReport { + plan_name: "Plan".to_string(), + billing_cycle_reset: Some("2026-09-01".to_string()), + breakdowns: vec![UsageBreakdown { + display_name: "Credits".to_string(), + used: 12.5, + limit, + percentage, + overage_charges: Some(1.25), + currency: Some("USD".to_string()), + }], + } + } + + fn configure_usage(backend: &FakeBackend, usage: Result) { + let mut state = backend.state(); + state.active = true; + state.usage = Some(usage); + } + + #[test] + fn parser_requires_exact_boundaries_and_preserves_unknown_slash_text() { + assert_eq!( + parse_command(" /models \n"), + Some(Command::ListConfig(ConfigCategory::Model)) + ); + assert_eq!(parse_command("/cancel-all"), Some(Command::CancelAll)); + assert_eq!( + parse_command("/reset now"), + Some(Command::InvalidArguments { + name: CommandName::Reset + }) + ); + assert_eq!( + parse_command("/model set Choice 1"), + Some(Command::SetConfig { + category: ConfigCategory::Model, + selector: "Choice 1".to_string() + }) + ); + assert_eq!( + parse_command("/agent list extra"), + Some(Command::InvalidArguments { + name: CommandName::Agents + }) + ); + assert_eq!(parse_command("/reset-now"), None); + assert_eq!(parse_command("/cancel-all-now"), None); + assert_eq!(parse_command("/usage-report"), None); + assert_eq!(parse_command("/compact"), None); + assert_eq!(parse_command("/Models"), None); + } + + #[tokio::test] + async fn text_config_list_is_current_first_and_bounded_to_25() { + let backend = Arc::new(FakeBackend::default()); + backend.state().options = vec![option("model", 28, 27)]; + let result = service(backend) + .execute(Command::ListConfig(ConfigCategory::Model), &context(true)) + .await; + let text = render_text_result(&result); + let Some(first_choice) = text.lines().nth(1) else { + panic!("rendered config list has no first choice"); + }; + assert!(first_choice.contains("Choice 27 ✅")); + assert!(text.contains("… 3 more option(s) omitted.")); + assert!(!text.contains("Choice 26")); + } + + #[tokio::test] + async fn agent_category_accepts_mode_and_selection_uses_full_option_set() { + let backend = Arc::new(FakeBackend::default()); + backend.state().options = vec![option("mode", 30, 0)]; + let result = service(backend.clone()) + .execute( + Command::SetConfig { + category: ConfigCategory::Agent, + selector: "Choice 29".to_string(), + }, + &context(true), + ) + .await; + assert!(matches!(result, CommandResult::ConfigUpdated { .. })); + assert_eq!(backend.state().set_calls.len(), 1); + } + + #[tokio::test] + async fn forged_config_payload_is_rejected_before_backend_mutation() { + let backend = Arc::new(FakeBackend::default()); + backend.state().options = vec![option("model", 2, 0)]; + let result = service(backend.clone()) + .set_config_value(&context(true), "forged", "value-1") + .await; + assert!(matches!( + result, + CommandResult::Error(CommandError::InvalidConfigSelection(_)) + )); + assert!(backend.state().set_calls.is_empty()); + } + + #[tokio::test] + async fn cancel_preserves_buffers_while_cancel_all_and_reset_clear_only_context_thread() { + let backend = Arc::new(FakeBackend::default()); + { + let mut state = backend.state(); + state.cancel_succeeds = true; + state.reset_succeeds = true; + state.buffers_cleared = true; + } + let service = service(backend.clone()); + service.execute(Command::Cancel, &context(true)).await; + assert!(backend.state().clear_calls.is_empty()); + + service.execute(Command::CancelAll, &context(true)).await; + service.execute(Command::Reset, &context(true)).await; + let state = backend.state(); + assert_eq!( + state.clear_calls, + vec![("teams".to_string(), "conversation".to_string()); 2] + ); + assert_eq!(state.cancel_calls, 2); + assert_eq!(state.reset_calls, 1); + } + + #[tokio::test] + async fn public_usage_is_denied_before_session_or_backend_access() { + let backend = Arc::new(FakeBackend::default()); + backend.state().active = true; + let result = service(backend.clone()) + .execute(Command::Usage, &context(false)) + .await; + assert!(matches!( + result, + CommandResult::Error(CommandError::UsagePrivateOnly) + )); + assert_eq!(backend.state().usage_calls, 0); + } + + #[tokio::test] + async fn usage_classifies_absent_unsupported_and_malformed_without_raw_errors() { + let backend = Arc::new(FakeBackend::default()); + let service = service(backend.clone()); + let absent = service.execute(Command::Usage, &context(true)).await; + assert!(matches!( + absent, + CommandResult::Error(CommandError::NoActiveSession) + )); + + configure_usage(&backend, Err(UsageFailure::Unsupported)); + let unsupported = service.execute(Command::Usage, &context(true)).await; + assert!(matches!( + unsupported, + CommandResult::Error(CommandError::UsageUnsupported) + )); + + backend.state().usage = Some(Err(UsageFailure::Unavailable)); + let malformed = service.execute(Command::Usage, &context(true)).await; + assert!(matches!( + malformed, + CommandResult::Error(CommandError::UsageUnavailable) + )); + } + + #[tokio::test(start_paused = true)] + async fn command_execution_timeout_returns_bounded_error() { + let backend = Arc::new(FakeBackend::default()); + configure_usage(&backend, Ok(usage_report(Some(10.0), Some(50)))); + backend.state().usage_delay = Some(std::time::Duration::from_secs(60)); + let result = service(backend) + .execute(Command::Usage, &context(true)) + .await; + assert!(matches!( + result, + CommandResult::Error(CommandError::OperationUnavailable) + )); + assert_eq!( + render_text_result(&result), + "⚠️ The command could not be completed." + ); + } + + #[tokio::test] + async fn usage_renderer_handles_over_limit_and_no_cap_reports() { + let backend = Arc::new(FakeBackend::default()); + configure_usage(&backend, Ok(usage_report(Some(10.0), Some(125)))); + let service = service(backend.clone()); + let over = service.execute(Command::Usage, &context(true)).await; + let over_text = render_text_result(&over); + assert!(over_text.contains("125% ⚠️")); + assert!(over_text.contains("Overage charges: 1.25 USD")); + + backend.state().usage = Some(Ok(usage_report(None, None))); + let no_cap = service.execute(Command::Usage, &context(true)).await; + assert!(render_text_result(&no_cap).contains("Credits: 12.50 used")); + } + + #[test] + fn renderer_bounds_untrusted_backend_strings() { + let text = render_text_result(&CommandResult::Usage(UsageReport { + plan_name: "x".repeat(10_000), + billing_cycle_reset: None, + breakdowns: vec![], + })); + assert!(text.chars().count() <= TEXT_RESPONSE_CHAR_LIMIT); + assert!(!text.contains(&"x".repeat(TEXT_VALUE_CHAR_LIMIT + 1))); + } + + #[test] + fn session_key_is_namespaced_by_platform() { + assert_eq!(context(true).session_key(), "teams:conversation"); + assert_ne!( + context(true).session_key(), + CommandContext::new("discord", "conversation", true).session_key() + ); + } + + #[test] + fn error_rendering_never_contains_backend_details() { + let expected = [ + ( + CommandError::ConfigUpdateUnavailable, + "❌ The configuration change could not be completed.", + ), + ( + CommandError::UsageUnavailable, + "⚠️ Usage information is temporarily unavailable.", + ), + ]; + for (error, message) in expected { + assert_eq!(render_text_result(&CommandResult::Error(error)), message); + } + } +} diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index a9bc26abd..98f1f2631 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -687,6 +687,10 @@ pub struct GatewayConfig { /// Show "…" placeholder at streaming start. Default: true. Set false for platforms using drafts. #[serde(default = "default_true")] pub streaming_placeholder: bool, + /// Maximum time to wait for a write acknowledgement advertised by a new + /// gateway peer. Legacy peers remain fire-and-forget. Default: 12 seconds. + #[serde(default = "default_gateway_ack_timeout_secs")] + pub gateway_ack_timeout_secs: u64, /// Whether the connected gateway renders tables natively (e.g. Telegram Rich Messages). /// Default: true (matches Telegram default). Set false if Rich Messages is disabled /// on the gateway daemon to preserve table code-block wrapping. @@ -707,6 +711,10 @@ fn default_gateway_platform() -> String { "telegram".into() } +fn default_gateway_ack_timeout_secs() -> u64 { + 12 +} + /// First-class `[telegram]` configuration section (see ADR: first-class /// per-platform config). Config-authoritative with `${ENV}` expansion; every /// field falls back to its `TELEGRAM_*` environment variable when unset, then to @@ -1291,8 +1299,19 @@ impl GoogleChatConfig { } } -/// First-class `[teams]` section — credentials, connection, and L3 identity -/// trust for the MS Teams adapter. Config-first invariant (#1375): each field +/// Opt-in Teams processing indicator. Message mode uses one turn-local bot +/// activity and the existing send/edit/delete acknowledgement contract. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TeamsProcessingIndicator { + #[default] + Off, + Message, +} + +/// First-class `[teams]` section — credentials, connection, typed L2 scope, +/// and L3 identity trust for the MS Teams adapter. Config-first invariant +/// (#1375): each field /// resolves `[teams].field` (with `${}` expansion) → `TEAMS_*` env var → /// default. Graduates from the shared [`PlatformTrustConfig`] (#1380). #[derive(Debug, Clone, Default, Deserialize)] @@ -1314,6 +1333,47 @@ pub struct TeamsConfig { /// Webhook mount path. Env fallback: `TEAMS_WEBHOOK_PATH` /// (default `/webhook/teams`). pub webhook_path: Option, + /// Process-local duplicate suppression window. Env fallback: + /// `TEAMS_DEDUPE_TTL_SECS` (default 600 seconds). + pub dedupe_ttl_secs: Option, + /// Ephemeral authenticated route lifetime. Env fallback: + /// `TEAMS_ROUTE_TTL_SECS` (default 3600 seconds). + pub route_ttl_secs: Option, + /// Shared capacity bound for route, dedupe, and ownership caches. Env fallback: + /// `TEAMS_MAX_ROUTE_ENTRIES` (default 10000). + pub max_route_entries: Option, + /// Opt in to the public-preview Bot Connector reaction API and advertise + /// the reaction status backend. Env fallback: `TEAMS_REACTIONS_ENABLED`. + /// Defaults to `false` so existing deployments remain side-effect free. + pub reactions_enabled: Option, + /// Opt in to one turn-local processing message. Env fallback: + /// `TEAMS_PROCESSING_INDICATOR`; default `off`. + pub processing_indicator: Option, + /// Opt in to progressive content through one real bot-owned placeholder. + /// Env fallback: `TEAMS_STREAMING`; default `false`. + pub streaming: Option, + /// Permit post-admission materialization of bounded inbound image/text + /// attachments. Env fallback: `TEAMS_INBOUND_ATTACHMENTS`; default `false`. + pub inbound_attachments: Option, + /// Opt-in persistent conversation registry file. Relative paths resolve + /// beneath `$HOME/.openab/`. Env: `TEAMS_CONVERSATION_REGISTRY_PATH`. + pub conversation_registry_path: Option, + /// Persistent registry entry cap. Env fallback: + /// `TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES`; default 1000. + pub conversation_registry_max_entries: Option, + /// Active/disabled registry retention. Env fallback: + /// `TEAMS_CONVERSATION_REGISTRY_TTL_SECS`; default one year. + pub conversation_registry_ttl_secs: Option, + /// Team IDs admitted by typed channel scope. Env fallback: + /// `TEAMS_ALLOWED_TEAMS` (comma-separated). Both scope lists empty = open. + pub allowed_teams: Option>, + /// Teams channel IDs admitted by typed channel scope. Env fallback: + /// `TEAMS_ALLOWED_CHANNELS` (comma-separated). Team OR channel match wins. + pub allowed_channels: Option>, + /// Admit Personal chats. Env fallback: `TEAMS_ALLOW_PERSONAL`; default true. + pub allow_personal: Option, + /// Admit group chats. Env fallback: `TEAMS_ALLOW_GROUP_CHATS`; default true. + pub allow_group_chats: Option, /// Explicit flag: true = allow all users, false = check `allowed_users`. /// Defaults to `false` (deny-all). Env fallback: `TEAMS_ALLOW_ALL_USERS`. pub allow_all_users: Option, @@ -1331,6 +1391,21 @@ pub struct ResolvedTeams { pub oauth_endpoint: String, pub openid_metadata: String, pub webhook_path: String, + pub dedupe_ttl_secs: u64, + pub route_ttl_secs: u64, + pub max_route_entries: usize, + pub reactions_enabled: bool, + pub processing_indicator: TeamsProcessingIndicator, + pub streaming: bool, + pub inbound_attachments: bool, + pub conversation_registry_path: Option, + pub conversation_registry_max_entries: usize, + pub conversation_registry_ttl_secs: u64, + pub allowed_teams: Vec, + pub allowed_channels: Vec, + pub allow_personal: bool, + pub allow_group_chats: bool, + pub scope_policy_configured: bool, pub allow_all_users: bool, pub allowed_users: Vec, } @@ -1355,6 +1430,68 @@ impl TeamsConfig { .collect(), } }; + let positive_u64 = |cfg: Option, env: &str, default: u64| { + cfg.filter(|value| *value > 0) + .or_else(|| { + std::env::var(env) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + }) + .unwrap_or(default) + }; + let positive_usize = |cfg: Option, env: &str, default: usize| { + cfg.filter(|value| *value > 0) + .or_else(|| { + std::env::var(env) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + }) + .unwrap_or(default) + }; + let bool_with_default = |cfg: Option, env: &str, default: bool| { + cfg.or_else(|| { + // An explicitly present but malformed switch resolves false. + // This is fail-closed for both admitted surfaces and opt-in UX. + std::env::var(env) + .ok() + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + }) + .unwrap_or(default) + }; + let processing_indicator = self.processing_indicator.unwrap_or_else(|| { + match std::env::var("TEAMS_PROCESSING_INDICATOR") { + Ok(value) if value.trim().eq_ignore_ascii_case("message") => { + TeamsProcessingIndicator::Message + } + Ok(value) + if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("off") => + { + TeamsProcessingIndicator::Off + } + Ok(_) => { + tracing::warn!( + key = "TEAMS_PROCESSING_INDICATOR", + "invalid Teams processing indicator; using off" + ); + TeamsProcessingIndicator::Off + } + Err(_) => TeamsProcessingIndicator::Off, + } + }); + let scope_policy_configured = self.allowed_teams.is_some() + || self.allowed_channels.is_some() + || self.allow_personal.is_some() + || self.allow_group_chats.is_some() + || [ + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", + ] + .into_iter() + .any(|key| std::env::var_os(key).is_some()); ResolvedTeams { app_id: opt_str(&self.app_id, "TEAMS_APP_ID"), app_secret: opt_str(&self.app_secret, "TEAMS_APP_SECRET"), @@ -1368,6 +1505,48 @@ impl TeamsConfig { }), webhook_path: opt_str(&self.webhook_path, "TEAMS_WEBHOOK_PATH") .unwrap_or_else(|| "/webhook/teams".into()), + dedupe_ttl_secs: positive_u64(self.dedupe_ttl_secs, "TEAMS_DEDUPE_TTL_SECS", 600), + route_ttl_secs: positive_u64(self.route_ttl_secs, "TEAMS_ROUTE_TTL_SECS", 3600), + max_route_entries: positive_usize( + self.max_route_entries, + "TEAMS_MAX_ROUTE_ENTRIES", + 10_000, + ), + reactions_enabled: self.reactions_enabled.unwrap_or_else(|| { + std::env::var("TEAMS_REACTIONS_ENABLED") + .ok() + .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) + }), + processing_indicator, + streaming: bool_with_default(self.streaming, "TEAMS_STREAMING", false), + inbound_attachments: bool_with_default( + self.inbound_attachments, + "TEAMS_INBOUND_ATTACHMENTS", + false, + ), + conversation_registry_path: opt_str( + &self.conversation_registry_path, + "TEAMS_CONVERSATION_REGISTRY_PATH", + ), + conversation_registry_max_entries: positive_usize( + self.conversation_registry_max_entries, + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", + 1_000, + ), + conversation_registry_ttl_secs: positive_u64( + self.conversation_registry_ttl_secs, + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS", + 365 * 24 * 60 * 60, + ), + allowed_teams: csv(&self.allowed_teams, "TEAMS_ALLOWED_TEAMS"), + allowed_channels: csv(&self.allowed_channels, "TEAMS_ALLOWED_CHANNELS"), + allow_personal: bool_with_default(self.allow_personal, "TEAMS_ALLOW_PERSONAL", true), + allow_group_chats: bool_with_default( + self.allow_group_chats, + "TEAMS_ALLOW_GROUP_CHATS", + true, + ), + scope_policy_configured, allow_all_users: self.allow_all_users.unwrap_or_else(|| { std::env::var("TEAMS_ALLOW_ALL_USERS") .ok() @@ -1756,6 +1935,8 @@ pub struct CronJobConfig { pub schedule: String, /// Target channel ID pub channel: String, + /// Required tenant identity for operator-owned Microsoft Teams jobs. + pub teams_tenant_id: Option, /// Message to send to the agent pub message: String, /// Target platform (default: "discord") @@ -2312,6 +2493,29 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result { ); anyhow::ensure!(s.max_batch_tokens > 0, "slack.max_batch_tokens must be > 0"); } + if let Some(ref teams) = config.teams { + if let Some(value) = teams.dedupe_ttl_secs { + anyhow::ensure!(value > 0, "teams.dedupe_ttl_secs must be > 0"); + } + if let Some(value) = teams.route_ttl_secs { + anyhow::ensure!(value > 0, "teams.route_ttl_secs must be > 0"); + } + if let Some(value) = teams.max_route_entries { + anyhow::ensure!(value > 0, "teams.max_route_entries must be > 0"); + } + if let Some(value) = teams.conversation_registry_max_entries { + anyhow::ensure!( + (1..=10_000).contains(&value), + "teams.conversation_registry_max_entries must be between 1 and 10000" + ); + } + if let Some(value) = teams.conversation_registry_ttl_secs { + anyhow::ensure!( + value > 0 && i64::try_from(value).is_ok(), + "teams.conversation_registry_ttl_secs is out of range" + ); + } + } if let Some(ref g) = config.gateway { anyhow::ensure!( g.max_buffered_messages > 0, @@ -2321,6 +2525,20 @@ fn parse_config_inner(expanded: &str, source: &str) -> anyhow::Result { g.max_batch_tokens > 0, "gateway.max_batch_tokens must be > 0" ); + anyhow::ensure!( + g.gateway_ack_timeout_secs > 0, + "gateway.gateway_ack_timeout_secs must be > 0" + ); + anyhow::ensure!( + g.gateway_ack_timeout_secs < config.pool.prompt_hard_timeout_secs, + "gateway.gateway_ack_timeout_secs must be less than pool.prompt_hard_timeout_secs" + ); + if g.platform == "teams" { + anyhow::ensure!( + g.gateway_ack_timeout_secs > 10, + "gateway.gateway_ack_timeout_secs must exceed the 10-second Teams Connector request timeout" + ); + } } anyhow::ensure!( config.pool.liveness_check_secs > 0, @@ -3022,7 +3240,24 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] /// separate process, safe). #[test] fn teams_resolve_all_scenarios() { - for k in ["TEAMS_APP_ID", "TEAMS_OAUTH_ENDPOINT"] { + for k in [ + "TEAMS_APP_ID", + "TEAMS_OAUTH_ENDPOINT", + "TEAMS_DEDUPE_TTL_SECS", + "TEAMS_ROUTE_TTL_SECS", + "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", + "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", + "TEAMS_CONVERSATION_REGISTRY_PATH", + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS", + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", + ] { std::env::remove_var(k); } // --- defaults --- @@ -3032,22 +3267,98 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert!(r.oauth_endpoint.contains("botframework.com")); assert!(r.openid_metadata.contains("openidconfiguration")); assert!(r.allowed_tenants.is_empty()); + assert_eq!(r.dedupe_ttl_secs, 600); + assert_eq!(r.route_ttl_secs, 3600); + assert_eq!(r.max_route_entries, 10_000); + assert!(!r.reactions_enabled); + assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); + assert!(!r.streaming); + assert!(!r.inbound_attachments); + assert!(r.conversation_registry_path.is_none()); + assert_eq!(r.conversation_registry_max_entries, 1_000); + assert_eq!(r.conversation_registry_ttl_secs, 365 * 24 * 60 * 60); + assert!(r.allowed_teams.is_empty()); + assert!(r.allowed_channels.is_empty()); + assert!(r.allow_personal); + assert!(r.allow_group_chats); + assert!(!r.scope_policy_configured); + assert!( + TeamsConfig { + allowed_teams: Some(vec![]), + ..Default::default() + } + .resolve() + .scope_policy_configured + ); // --- config wins over env --- std::env::set_var("TEAMS_APP_ID", "env-app"); std::env::set_var("TEAMS_OAUTH_ENDPOINT", "https://env.example/token"); + std::env::set_var("TEAMS_DEDUPE_TTL_SECS", "41"); + std::env::set_var("TEAMS_ROUTE_TTL_SECS", "83"); + std::env::set_var("TEAMS_MAX_ROUTE_ENTRIES", "122"); + std::env::set_var("TEAMS_REACTIONS_ENABLED", "false"); + std::env::set_var("TEAMS_PROCESSING_INDICATOR", "off"); + std::env::set_var("TEAMS_STREAMING", "false"); + std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "false"); + std::env::set_var("TEAMS_CONVERSATION_REGISTRY_PATH", "env-registry.json"); + std::env::set_var("TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", "121"); + std::env::set_var("TEAMS_CONVERSATION_REGISTRY_TTL_SECS", "82"); + std::env::set_var("TEAMS_ALLOWED_TEAMS", "env-team"); + std::env::set_var("TEAMS_ALLOWED_CHANNELS", "env-channel"); + std::env::set_var("TEAMS_ALLOW_PERSONAL", "false"); + std::env::set_var("TEAMS_ALLOW_GROUP_CHATS", "false"); let cfg = TeamsConfig { app_id: Some("cfg-app".into()), oauth_endpoint: Some("https://cfg.example/token".into()), allowed_tenants: Some(vec!["t1".into(), "t2".into()]), + dedupe_ttl_secs: Some(42), + route_ttl_secs: Some(84), + max_route_entries: Some(123), + reactions_enabled: Some(true), + processing_indicator: Some(TeamsProcessingIndicator::Message), + streaming: Some(true), + inbound_attachments: Some(true), + conversation_registry_path: Some("cfg-registry.json".into()), + conversation_registry_max_entries: Some(124), + conversation_registry_ttl_secs: Some(85), + allowed_teams: Some(vec!["cfg-team".into()]), + allowed_channels: Some(vec![]), + allow_personal: Some(true), + allow_group_chats: Some(true), ..Default::default() }; let r = cfg.resolve(); assert_eq!(r.app_id.as_deref(), Some("cfg-app")); assert_eq!(r.oauth_endpoint, "https://cfg.example/token"); assert_eq!(r.allowed_tenants, vec!["t1".to_string(), "t2".to_string()]); + assert_eq!(r.dedupe_ttl_secs, 42); + assert_eq!(r.route_ttl_secs, 84); + assert_eq!(r.max_route_entries, 123); + assert!(r.reactions_enabled); + assert_eq!( + r.processing_indicator, + TeamsProcessingIndicator::Message + ); + assert!(r.streaming); + assert!(r.inbound_attachments); + assert_eq!( + r.conversation_registry_path.as_deref(), + Some("cfg-registry.json") + ); + assert_eq!(r.conversation_registry_max_entries, 124); + assert_eq!(r.conversation_registry_ttl_secs, 85); + assert_eq!(r.allowed_teams, vec!["cfg-team"]); + assert!(r.allowed_channels.is_empty()); + assert!(r.allow_personal); + assert!(r.allow_group_chats); + assert!(r.scope_policy_configured); // --- empty-string ${} expansion falls through to env --- + std::env::set_var("TEAMS_REACTIONS_ENABLED", "true"); + std::env::set_var("TEAMS_PROCESSING_INDICATOR", "message"); + std::env::set_var("TEAMS_STREAMING", "true"); + std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "true"); let cfg = TeamsConfig { app_id: Some("".into()), ..Default::default() @@ -3055,6 +3366,49 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] let r = cfg.resolve(); assert_eq!(r.app_id.as_deref(), Some("env-app")); assert_eq!(r.oauth_endpoint, "https://env.example/token"); + assert_eq!(r.dedupe_ttl_secs, 41); + assert_eq!(r.route_ttl_secs, 83); + assert_eq!(r.max_route_entries, 122); + assert!(r.reactions_enabled); + assert_eq!( + r.processing_indicator, + TeamsProcessingIndicator::Message + ); + assert!(r.streaming); + assert!(r.inbound_attachments); + assert_eq!( + r.conversation_registry_path.as_deref(), + Some("env-registry.json") + ); + assert_eq!(r.conversation_registry_max_entries, 121); + assert_eq!(r.conversation_registry_ttl_secs, 82); + assert_eq!(r.allowed_teams, vec!["env-team"]); + assert_eq!(r.allowed_channels, vec!["env-channel"]); + assert!(!r.allow_personal); + assert!(!r.allow_group_chats); + assert!(r.scope_policy_configured); + + // --- strict numeric boolean forms --- + std::env::set_var("TEAMS_STREAMING", "1"); + std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "1"); + assert!(TeamsConfig::default().resolve().streaming); + assert!(TeamsConfig::default().resolve().inbound_attachments); + std::env::set_var("TEAMS_STREAMING", "0"); + std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "0"); + assert!(!TeamsConfig::default().resolve().streaming); + assert!(!TeamsConfig::default().resolve().inbound_attachments); + + // --- malformed switches fail closed --- + std::env::set_var("TEAMS_ALLOW_PERSONAL", "not-a-boolean"); + std::env::set_var("TEAMS_PROCESSING_INDICATOR", "typing"); + std::env::set_var("TEAMS_STREAMING", "not-a-boolean"); + std::env::set_var("TEAMS_INBOUND_ATTACHMENTS", "not-a-boolean"); + let r = TeamsConfig::default().resolve(); + assert!(!r.allow_personal); + assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); + assert!(!r.streaming); + assert!(!r.inbound_attachments); + assert!(r.scope_policy_configured); // --- trust_config() view --- let cfg = TeamsConfig { @@ -3069,8 +3423,76 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] Some(&["29:abc".to_string()][..]) ); - std::env::remove_var("TEAMS_APP_ID"); - std::env::remove_var("TEAMS_OAUTH_ENDPOINT"); + for k in [ + "TEAMS_APP_ID", + "TEAMS_OAUTH_ENDPOINT", + "TEAMS_DEDUPE_TTL_SECS", + "TEAMS_ROUTE_TTL_SECS", + "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", + "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", + "TEAMS_CONVERSATION_REGISTRY_PATH", + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS", + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", + ] { + std::env::remove_var(k); + } + } + + #[test] + fn teams_processing_indicator_rejects_unknown_toml_value() { + let error = parse_config( + "[teams]\nprocessing_indicator = \"typing\"\n", + "test", + ) + .unwrap_err(); + assert!(error.to_string().contains("processing_indicator")); + } + + #[test] + fn teams_streaming_rejects_non_boolean_toml_value() { + let error = parse_config("[teams]\nstreaming = \"yes\"\n", "test").unwrap_err(); + assert!(error.to_string().contains("streaming")); + } + + #[test] + fn teams_inbound_attachments_rejects_non_boolean_toml_value() { + let error = parse_config("[teams]\ninbound_attachments = \"yes\"\n", "test").unwrap_err(); + assert!(error.to_string().contains("inbound_attachments")); + } + + #[test] + fn teams_runtime_bounds_reject_zero() { + for key in ["dedupe_ttl_secs", "route_ttl_secs", "max_route_entries"] { + let raw = format!("[teams]\n{key} = 0\n"); + let error = parse_config(&raw, "test").unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("teams.{key} must be > 0")), + "unexpected error for {key}: {error}" + ); + } + } + + #[test] + fn teams_conversation_registry_bounds_are_closed() { + for value in [0, 10_001] { + let raw = format!("[teams]\nconversation_registry_max_entries = {value}\n"); + let error = parse_config(&raw, "test").unwrap_err(); + assert!(error + .to_string() + .contains("conversation_registry_max_entries")); + } + let error = + parse_config("[teams]\nconversation_registry_ttl_secs = 0\n", "test").unwrap_err(); + assert!(error.to_string().contains("conversation_registry_ttl_secs")); } /// All `FEISHU_*` env scenarios in ONE test (env is process-global). @@ -3203,6 +3625,10 @@ allowed_users = ["users/123456789"] [teams] app_id = "app-1" allow_all_users = true +allowed_teams = ["team-1"] +allowed_channels = ["channel-1"] +allow_personal = false +allow_group_chats = true [lineworks] bot_id = "123" @@ -3226,6 +3652,16 @@ allowed_users = ["uuid-a", "uuid-b"] let teams = cfg.teams.expect("teams section"); assert_eq!(teams.app_id.as_deref(), Some("app-1")); assert_eq!(teams.allow_all_users, Some(true)); + assert_eq!( + teams.allowed_teams.as_deref(), + Some(&["team-1".to_string()][..]) + ); + assert_eq!( + teams.allowed_channels.as_deref(), + Some(&["channel-1".to_string()][..]) + ); + assert_eq!(teams.allow_personal, Some(false)); + assert_eq!(teams.allow_group_chats, Some(true)); let lw = cfg.lineworks.expect("lineworks section"); assert_eq!(lw.bot_id.as_deref(), Some("123")); assert_eq!(lw.allow_all_users, None); @@ -3545,6 +3981,7 @@ command = "echo" let gw = cfg.gateway.unwrap(); assert_eq!(gw.url, "ws://gw:8080/ws"); assert_eq!(gw.platform, "telegram"); + assert_eq!(gw.gateway_ack_timeout_secs, 12); assert!(gw.allowed_users.is_empty()); assert!(gw.allowed_channels.is_empty()); assert!(gw.allow_all_users.is_none()); @@ -3557,6 +3994,57 @@ command = "echo" )); } + #[test] + fn parse_gateway_ack_timeout_override() { + let toml = r#" +[gateway] +url = "wss://gw.example/ws" +gateway_ack_timeout_secs = 30 + +[agent] +command = "echo" +"#; + let cfg = parse_config(toml, "test").unwrap(); + assert_eq!(cfg.gateway.unwrap().gateway_ack_timeout_secs, 30); + } + + #[test] + fn parse_gateway_ack_timeout_rejects_invalid_budgets() { + let zero = r#" +[gateway] +url = "wss://gw.example/ws" +gateway_ack_timeout_secs = 0 + +[agent] +command = "echo" +"#; + assert!(parse_config(zero, "test").is_err()); + + let teams_too_short = r#" +[gateway] +url = "wss://gw.example/ws" +platform = "teams" +gateway_ack_timeout_secs = 10 + +[agent] +command = "echo" +"#; + assert!(parse_config(teams_too_short, "test").is_err()); + + let beyond_turn = r#" +[gateway] +url = "wss://gw.example/ws" +gateway_ack_timeout_secs = 12 + +[pool] +prompt_hard_timeout_secs = 12 + +[agent] +command = "echo" +"#; + assert!(parse_config(beyond_turn, "test").is_err()); + } + #[test] fn parse_gateway_config_with_allowlists() { let toml = r#" diff --git a/crates/openab-core/src/cron.rs b/crates/openab-core/src/cron.rs index 40e9a67ca..fa09bac4b 100644 --- a/crates/openab-core/src/cron.rs +++ b/crates/openab-core/src/cron.rs @@ -1,4 +1,6 @@ -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, SenderContext}; +use crate::adapter::{ + AdapterRouter, ChannelRef, ChatAdapter, PersistentConversationTarget, SenderContext, +}; use crate::config::CronJobConfig; use crate::format; use chrono::{Timelike, Utc}; @@ -238,13 +240,21 @@ pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool { } /// Known platforms that have adapter support. -const VALID_PLATFORMS: &[&str] = &["discord", "slack", "telegram", "googlechat", "lineworks"]; - -/// Cron platforms that must NOT get a synthetic thread: Google Chat cron -/// messages stay top-level by design, and LINE WORKS has no thread/topic API -/// (its reply dispatch ignores topic creation), so a synthetic thread would -/// silently deliver to the flat channel instead. -const CRON_THREADLESS_PLATFORMS: &[&str] = &["googlechat", "lineworks"]; +const VALID_PLATFORMS: &[&str] = &[ + "discord", + "slack", + "telegram", + "googlechat", + "lineworks", + "teams", +]; + +/// Cron platforms that must NOT get a synthetic thread. Their configured +/// destination already is the complete routing surface. +const CRON_THREADLESS_PLATFORMS: &[&str] = &["googlechat", "lineworks", "teams"]; +const TEAMS_BOT_FRAMEWORK_CHANNEL_ID: &str = "msteams"; +const TEAMS_TENANT_ID_MAX_BYTES: usize = 256; +const TEAMS_CONVERSATION_ID_MAX_BYTES: usize = 2_048; fn should_create_cron_thread(job: &CronJobConfig) -> bool { job.thread_id.is_none() && !CRON_THREADLESS_PLATFORMS.contains(&job.platform.as_str()) @@ -257,12 +267,78 @@ fn cron_sender_thread_id(channel: &ChannelRef) -> Option { .or_else(|| channel.parent_id.as_ref().map(|_| channel.channel_id.clone())) } -/// Validate all cronjob configs (fail-fast on bad cron expressions or timezones). +fn valid_bounded_identifier(value: &str, max_bytes: usize) -> bool { + !value.trim().is_empty() + && value.trim() == value + && value.len() <= max_bytes + && !value.chars().any(char::is_control) +} + +fn validate_cron_target_fields(index: usize, job: &CronJobConfig) -> anyhow::Result<()> { + if job.platform == "teams" { + let tenant_id = job.teams_tenant_id.as_deref().ok_or_else(|| { + anyhow::anyhow!("cronjobs[{index}]: Teams jobs require teams_tenant_id") + })?; + if !valid_bounded_identifier(tenant_id, TEAMS_TENANT_ID_MAX_BYTES) { + anyhow::bail!("cronjobs[{index}]: Teams tenant identity is invalid"); + } + if !valid_bounded_identifier(&job.channel, TEAMS_CONVERSATION_ID_MAX_BYTES) { + anyhow::bail!("cronjobs[{index}]: Teams conversation identity is invalid"); + } + if job.thread_id.is_some() { + anyhow::bail!("cronjobs[{index}]: Teams jobs must not set thread_id"); + } + if job.id.is_some() + || job.disable_on_success.is_some() + || job.disable_on_success_match.is_some() + || job.disable_on_success_working_dir.is_some() + || job.disable_on_success_timeout_secs != 60 + { + anyhow::bail!("cronjobs[{index}]: Teams baseline jobs contain a usercron-only field"); + } + } else if job.teams_tenant_id.is_some() { + anyhow::bail!( + "cronjobs[{index}]: teams_tenant_id is only valid when platform is \"teams\"" + ); + } + Ok(()) +} + +fn cron_channel_ref(job: &CronJobConfig) -> anyhow::Result { + let persistent_conversation = if job.platform == "teams" { + let tenant_id = job + .teams_tenant_id + .as_deref() + .filter(|value| valid_bounded_identifier(value, TEAMS_TENANT_ID_MAX_BYTES)) + .ok_or_else(|| anyhow::anyhow!("Teams cron target is invalid"))?; + if !valid_bounded_identifier(&job.channel, TEAMS_CONVERSATION_ID_MAX_BYTES) { + anyhow::bail!("Teams cron target is invalid"); + } + Some(Box::new(PersistentConversationTarget { + tenant_id: tenant_id.to_owned(), + bot_framework_channel_id: TEAMS_BOT_FRAMEWORK_CHANNEL_ID.into(), + conversation_id: job.channel.clone(), + })) + } else { + None + }; + Ok(ChannelRef { + platform: job.platform.clone(), + channel_id: job.channel.clone(), + thread_id: job.thread_id.clone(), + parent_id: None, + persistent_conversation, + origin_event_id: None, + }) +} + +/// Validate all baseline cron configs before scheduler or adapter side effects. pub fn validate_cronjobs( cronjobs: &[CronJobConfig], configured_platforms: &[&str], ) -> anyhow::Result<()> { for (i, job) in cronjobs.iter().enumerate() { + validate_cron_target_fields(i, job)?; if !job.enabled { continue; } @@ -327,8 +403,17 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec, ) -> Vec { - configs.iter().filter(|job| { + configs + .iter() + .filter(|job| { if !job.enabled { + if job.platform == "teams" { + info!(schedule = %job.schedule, source, "Teams cronjob disabled, skipping"); + } else { info!(schedule = %job.schedule, channel = %job.channel, source, "cronjob disabled, skipping"); } + } job.enabled - }).filter_map(|job| { + }) + .filter_map(|job| { let schedule = match parse_cron_expr(&job.schedule) { - Ok(s) => s, - Err(e) => { - error!(schedule = %job.schedule, error = %e, source, "invalid cron expression, skipping"); + Ok(schedule) => schedule, + Err(error) => { + error!(schedule = %job.schedule, error = %error, source, "invalid cron expression, skipping"); return None; } }; let tz: Tz = match job.timezone.parse() { - Ok(t) => t, - Err(e) => { - error!(timezone = %job.timezone, error = %e, source, "invalid timezone, skipping"); + Ok(timezone) => timezone, + Err(error) => { + error!(timezone = %job.timezone, error = %error, source, "invalid timezone, skipping"); return None; } }; + if job.platform == "teams" { info!( - schedule = %job.schedule, timezone = %job.timezone, - channel = %job.channel, platform = %job.platform, - message = %job.message, source, + schedule = %job.schedule, + timezone = %job.timezone, + platform = "teams", + source, + "Teams cronjob registered" + ); + } else { + info!( + schedule = %job.schedule, + timezone = %job.timezone, + channel = %job.channel, + platform = %job.platform, + message = %job.message, + source, "cronjob registered" ); + } Some(ParsedJob { schedule, tz, config: job.clone(), usercron_path: usercron_path.map(Path::to_path_buf), }) - }).collect() + }) + .collect() } /// Run the internal cron scheduler. Evaluates cron expressions once per minute. @@ -513,10 +619,22 @@ pub async fn run_scheduler( { let running = in_flight.lock().await; if running.contains(&idx) { + if job.config.platform == "teams" { + warn!(schedule = %job.config.schedule, platform = "teams", "skipping cronjob, previous execution still running"); + } else { warn!(schedule = %job.config.schedule, channel = %job.config.channel, "skipping cronjob, previous execution still running"); + } continue; } } + if job.config.platform == "teams" { + info!( + schedule = %job.config.schedule, + platform = "teams", + source = if job.usercron_path.is_some() { "usercron" } else { "baseline" }, + "cronjob fired" + ); + } else { info!( schedule = %job.config.schedule, channel = %job.config.channel, @@ -525,6 +643,7 @@ pub async fn run_scheduler( sender = %job.config.sender_name, "🔔 cronjob fired" ); + } in_flight.lock().await.insert(idx); let config = job.config.clone(); @@ -591,13 +710,41 @@ async fn fire_cronjob( }; let adapter = match adapters.get(&job.platform) { - Some(a) => a.clone(), + Some(adapter) => adapter.clone(), None => { error!(platform = %job.platform, "no adapter for platform, skipping cronjob"); return; } }; + if job.platform == "teams" { + let capabilities = adapter.capabilities("teams"); + if !capabilities.send_ack || !capabilities.supports_persistent_conversation_send { + warn!( + platform = "teams", + operation = "persistent_trigger_send", + "Teams cron persistent-send capability is unavailable; skipping execution" + ); + return; + } + } + + let thread_channel = match cron_channel_ref(job) { + Ok(channel) => channel, + Err(error) => { + if job.platform == "teams" { + warn!( + platform = "teams", + operation = "target_build", + "Teams cron target is invalid" + ); + } else { + error!(platform = %job.platform, error = %error, "failed to build cron target"); + } + return; + } + }; + if let Some(command) = non_empty_opt(job.disable_on_success.as_deref()) { let marker = match non_empty_opt(job.disable_on_success_match.as_deref()) { Some(marker) => marker, @@ -612,16 +759,9 @@ async fn fire_cronjob( if !marker.is_empty() { match check_disable_on_success(job, command, marker).await { DisableOnSuccessResult::Achieved => { - let channel = ChannelRef { - platform: job.platform.clone(), - channel_id: job.channel.clone(), - thread_id: job.thread_id.clone(), - parent_id: None, - origin_event_id: None, - }; if let Err(e) = adapter .send_message( - &channel, + &thread_channel, &format!( "✅ Goal achieved: `{}` matched `{}`. Disabling cronjob.", command, marker @@ -655,14 +795,6 @@ async fn fire_cronjob( } } - let thread_channel = ChannelRef { - platform: job.platform.clone(), - channel_id: job.channel.clone(), - thread_id: job.thread_id.clone(), - parent_id: None, - origin_event_id: None, - }; - let trigger_msg = match adapter .send_message( &thread_channel, @@ -670,9 +802,26 @@ async fn fire_cronjob( ) .await { - Ok(msg) => msg, - Err(e) => { - error!(channel = %job.channel, error = %e, "failed to send cron message"); + Ok(message) if job.platform == "teams" && message.message_id.trim().is_empty() => { + warn!( + platform = "teams", + operation = "persistent_trigger_send", + outcome = "unknown", + "Teams cron trigger returned no activity id; skipping agent work" + ); + return; + } + Ok(message) => message, + Err(error) => { + if job.platform == "teams" { + warn!( + platform = "teams", + operation = "persistent_trigger_send", + "Teams cron trigger was not delivered; skipping agent work" + ); + } else { + error!(channel = %job.channel, error = %error, "failed to send cron message"); + } return; } }; @@ -1255,6 +1404,7 @@ message = "hello" assert_eq!(job.sender_name, "openab-cron"); assert_eq!(job.timezone, "UTC"); assert!(job.thread_id.is_none()); + assert!(job.teams_tenant_id.is_none()); assert!(job.id.is_none()); assert!(job.disable_on_success.is_none()); assert!(job.disable_on_success_match.is_none()); @@ -1333,6 +1483,26 @@ message = "ping" assert_eq!(jobs[0].message, "ping"); } + #[test] + fn load_usercron_rejects_teams_even_when_configured() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +schedule = "* * * * *" +channel = "conversation-1" +teams_tenant_id = "tenant-1" +message = "must not run" +platform = "teams" +"#, + ) + .unwrap(); + + assert!(load_usercron_file(&path, &["teams"]).is_empty()); + } + #[test] fn load_usercron_invalid_toml_returns_empty() { let dir = tempfile::tempdir().unwrap(); @@ -1439,6 +1609,7 @@ disable_on_success = "echo SUCCESS" enabled: true, schedule: "* * * * *".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1548,6 +1719,7 @@ message = "a" enabled: true, schedule: "* * * * *".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1571,6 +1743,31 @@ message = "a" assert!(!should_create_cron_thread(&job)); } + #[test] + fn teams_cron_target_is_exact_and_threadless() { + let mut job = test_cron_job(); + job.id = None; + job.platform = "teams".into(); + job.channel = "conversation-1".into(); + job.teams_tenant_id = Some("tenant-1".into()); + job.disable_on_success = None; + job.disable_on_success_match = None; + + assert!(!should_create_cron_thread(&job)); + let channel = cron_channel_ref(&job).unwrap(); + assert_eq!(channel.channel_id, "conversation-1"); + assert!(channel.thread_id.is_none()); + assert!(channel.origin_event_id.is_none()); + assert_eq!( + channel.persistent_conversation, + Some(Box::new(PersistentConversationTarget { + tenant_id: "tenant-1".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation-1".into(), + })) + ); + } + #[test] fn lineworks_cron_never_requests_synthetic_thread() { // LINE WORKS has no thread API — a synthetic cron thread would be @@ -1598,6 +1795,7 @@ message = "a" channel_id: "spaces/TEST".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }; @@ -1611,6 +1809,7 @@ message = "a" channel_id: "spaces/TEST".into(), thread_id: Some("spaces/TEST/threads/THREAD".into()), parent_id: None, + persistent_conversation: None, origin_event_id: None, }; @@ -1627,10 +1826,14 @@ message = "a" channel_id: "thread-456".into(), thread_id: None, parent_id: Some("channel-123".into()), + persistent_conversation: None, origin_event_id: None, }; - assert_eq!(cron_sender_thread_id(&channel).as_deref(), Some("thread-456")); + assert_eq!( + cron_sender_thread_id(&channel).as_deref(), + Some("thread-456") + ); } // --- validate_cronjobs tests --- @@ -1642,6 +1845,7 @@ message = "a" enabled: true, schedule: "0 9 * * 1-5".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1666,6 +1870,55 @@ message = "a" assert!(validate_cronjobs(&[job], &["googlechat"]).is_ok()); } + #[test] + fn validate_cronjobs_accepts_bounded_teams_baseline() { + let mut job = test_cron_job(); + job.id = None; + job.platform = "teams".into(); + job.channel = "conversation-1".into(); + job.teams_tenant_id = Some("tenant-1".into()); + job.disable_on_success = None; + job.disable_on_success_match = None; + + assert!(validate_cronjobs(&[job], &["teams"]).is_ok()); + } + + #[test] + fn validate_cronjobs_rejects_invalid_teams_target_shapes() { + let mut job = test_cron_job(); + job.id = None; + job.platform = "teams".into(); + job.channel = "conversation-1".into(); + job.teams_tenant_id = Some("tenant-1".into()); + job.disable_on_success = None; + job.disable_on_success_match = None; + + let mut missing_tenant = job.clone(); + missing_tenant.teams_tenant_id = None; + assert!(validate_cronjobs(&[missing_tenant], &["teams"]).is_err()); + + let mut empty_tenant = job.clone(); + empty_tenant.teams_tenant_id = Some(String::new()); + assert!(validate_cronjobs(&[empty_tenant], &["teams"]).is_err()); + + let mut oversized_tenant = job.clone(); + oversized_tenant.teams_tenant_id = Some("t".repeat(TEAMS_TENANT_ID_MAX_BYTES + 1)); + assert!(validate_cronjobs(&[oversized_tenant], &["teams"]).is_err()); + + let mut threaded = job.clone(); + threaded.thread_id = Some("thread-1".into()); + assert!(validate_cronjobs(&[threaded], &["teams"]).is_err()); + + let mut usercron_field = job.clone(); + usercron_field.enabled = false; + usercron_field.disable_on_success = Some("echo done".into()); + assert!(validate_cronjobs(&[usercron_field], &["teams"]).is_err()); + + let mut cross_platform = job; + cross_platform.platform = "discord".into(); + assert!(validate_cronjobs(&[cross_platform], &["discord"]).is_err()); + } + #[test] fn validate_cronjobs_invalid_cron_fails() { let jobs = vec![CronJobConfig { @@ -1673,6 +1926,7 @@ message = "a" enabled: true, schedule: "bad".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1694,6 +1948,7 @@ message = "a" enabled: true, schedule: "* * * * *".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1715,6 +1970,7 @@ message = "a" enabled: true, schedule: "* * * * *".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "matrix".into(), sender_name: "test".into(), @@ -1736,6 +1992,7 @@ message = "a" enabled: true, schedule: "* * * * *".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "slack".into(), sender_name: "test".into(), @@ -1757,6 +2014,7 @@ message = "a" enabled: false, schedule: "bad".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1777,6 +2035,7 @@ message = "a" enabled: true, schedule: "bad".into(), channel: "123".into(), + teams_tenant_id: None, message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), @@ -1831,13 +2090,25 @@ schedule = "*/30 * * * *" channel = "456" message = "ping" platform = "slack" + +[[cron.jobs]] +schedule = "0 9 * * 1-5" +channel = "conversation-1" +teams_tenant_id = "tenant-1" +message = "scheduled" +platform = "teams" "#; let cfg: Config = toml::from_str(toml_str).unwrap(); assert!(cfg.cron.usercron_enabled); assert_eq!(cfg.cron.usercron_path.as_deref(), Some("cronjob.toml")); - assert_eq!(cfg.cron.jobs.len(), 2); + assert_eq!(cfg.cron.jobs.len(), 3); assert_eq!(cfg.cron.jobs[0].message, "hello"); assert_eq!(cfg.cron.jobs[1].platform, "slack"); + assert_eq!(cfg.cron.jobs[2].platform, "teams"); + assert_eq!( + cfg.cron.jobs[2].teams_tenant_id.as_deref(), + Some("tenant-1") + ); } #[test] diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 609bd7459..23842159a 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -2,6 +2,10 @@ use crate::acp::protocol::{ConfigOption, UsageReport}; use crate::acp::ContentBlock; use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX}; +use crate::commands::{ + render_text_result, Command as CoreCommand, CommandContext, CommandResult, CommandService, + ConfigCategory, +}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::dispatch::DispatchTarget; use crate::format; @@ -20,7 +24,7 @@ use serenity::model::application::ButtonStyle; use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, Reaction, ReactionType}; use serenity::model::gateway::Ready; -use serenity::model::id::{ChannelId, MessageId, UserId}; +use serenity::model::id::{ChannelId, GuildId, MessageId, UserId}; use serenity::prelude::*; use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; @@ -178,6 +182,7 @@ impl ChatAdapter for DiscordAdapter { channel_id: thread.id.to_string(), thread_id: None, parent_id: Some(channel.channel_id.clone()), + persistent_conversation: None, origin_event_id: None, }) } @@ -586,6 +591,7 @@ impl EventHandler for Handler { channel_id: channel_id.to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }; @@ -718,8 +724,7 @@ impl EventHandler for Handler { // @mention in an ambient context → discard buffer + normal dispatch. // NOTE: Bot messages without @mention are already handled by the // early-route above; this block handles human messages and bot @mentions. - if in_ambient_context { - let ambient = self.ambient.as_ref().unwrap(); + if let Some(ambient) = self.ambient.as_ref().filter(|_| in_ambient_context) { if !is_dm { if is_mentioned { // Discard ambient buffer — mention takes priority. @@ -749,6 +754,7 @@ impl EventHandler for Handler { channel_id: channel_id.to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }; @@ -1032,6 +1038,7 @@ impl EventHandler for Handler { channel_id: msg.channel_id.get().to_string(), thread_id: None, parent_id: thread_parent_id.clone(), + persistent_conversation: None, origin_event_id: None, } } else { @@ -1123,7 +1130,7 @@ impl EventHandler for Handler { return; } } - let sender_json = serde_json::to_string(&sender).unwrap(); + let sender_json = serde_json::to_string(&sender).unwrap_or_default(); let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id); let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks); let buf_msg = crate::dispatch::BufferedMessage { @@ -1236,24 +1243,32 @@ impl EventHandler for Handler { if !in_allowed_thread { return; } - (ChannelRef { - platform: "discord".into(), - channel_id: channel_id.get().to_string(), - thread_id: None, - parent_id: parent.map(|p| p.to_string()), - origin_event_id: None, - }, true) + ( + ChannelRef { + platform: "discord".into(), + channel_id: channel_id.get().to_string(), + thread_id: None, + parent_id: parent.map(|p| p.to_string()), + persistent_conversation: None, + origin_event_id: None, + }, + true, + ) } else { if !in_allowed_channel { return; } - (ChannelRef { - platform: "discord".into(), - channel_id: channel_id.get().to_string(), - thread_id: None, - parent_id: None, - origin_event_id: None, - }, false) + ( + ChannelRef { + platform: "discord".into(), + channel_id: channel_id.get().to_string(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: None, + }, + false, + ) } } _ => return, @@ -1344,6 +1359,7 @@ impl EventHandler for Handler { channel_id: channel_id.get().to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: message_id.to_string(), @@ -1364,7 +1380,7 @@ impl EventHandler for Handler { let sender_id = sender.sender_id.clone(); let sender_name_clone = sender.sender_name.clone(); - let sender_json = serde_json::to_string(&sender).unwrap(); + let sender_json = serde_json::to_string(&sender).unwrap_or_default(); let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id); let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &[]); let buf_msg = crate::dispatch::BufferedMessage { @@ -1485,22 +1501,48 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { match interaction { - Interaction::Command(cmd) if cmd.data.name == "models" => { - self.handle_config_command(&ctx, &cmd, "model", "model") - .await; - } - Interaction::Command(cmd) if cmd.data.name == "agents" => { - self.handle_config_command(&ctx, &cmd, "agent", "agent") - .await; - } - Interaction::Command(cmd) if cmd.data.name == "cancel" => { - self.handle_cancel_command(&ctx, &cmd).await; - } - Interaction::Command(cmd) if cmd.data.name == "cancel-all" => { - self.handle_cancel_all_command(&ctx, &cmd).await; - } - Interaction::Command(cmd) if cmd.data.name == "reset" => { - self.handle_reset_command(&ctx, &cmd).await; + Interaction::Command(cmd) + if matches!( + cmd.data.name.as_str(), + "models" | "agents" | "cancel" | "cancel-all" | "reset" | "usage" + ) => + { + if let Err(message) = self + .shared_command_admission( + &ctx, + cmd.channel_id, + cmd.guild_id, + cmd.user.id, + cmd.user.bot, + ) + .await + { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(message) + .ephemeral(true), + ); + if cmd.create_response(&ctx.http, response).await.is_err() { + tracing::error!("failed to deny Discord command interaction"); + } + return; + } + + match cmd.data.name.as_str() { + "models" => { + self.handle_config_command(&ctx, &cmd, ConfigCategory::Model, "model") + .await; + } + "agents" => { + self.handle_config_command(&ctx, &cmd, ConfigCategory::Agent, "agent") + .await; + } + "cancel" => self.handle_cancel_command(&ctx, &cmd).await, + "cancel-all" => self.handle_cancel_all_command(&ctx, &cmd).await, + "reset" => self.handle_reset_command(&ctx, &cmd).await, + "usage" => self.handle_usage_command(&ctx, &cmd).await, + _ => unreachable!("guard restricts shared command names"), + } } Interaction::Command(cmd) if cmd.data.name == "remind" => { self.handle_remind_command(&ctx, &cmd).await; @@ -1511,14 +1553,35 @@ impl EventHandler for Handler { Interaction::Command(cmd) if cmd.data.name == "auth" => { self.handle_auth_command(&ctx, &cmd).await; } - Interaction::Command(cmd) if cmd.data.name == "usage" => { - self.handle_usage_command(&ctx, &cmd).await; - } - Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { - self.handle_config_select(&ctx, &comp).await; - } - Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_pg:") => { - self.handle_pagination(&ctx, &comp).await; + Interaction::Component(comp) + if comp.data.custom_id.starts_with("acp_config_") + || comp.data.custom_id.starts_with("acp_pg:") => + { + if let Err(message) = self + .shared_command_admission( + &ctx, + comp.channel_id, + comp.guild_id, + comp.user.id, + comp.user.bot, + ) + .await + { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(message) + .ephemeral(true), + ); + if comp.create_response(&ctx.http, response).await.is_err() { + tracing::error!("failed to deny Discord command component"); + } + return; + } + if comp.data.custom_id.starts_with("acp_config_") { + self.handle_config_select(&ctx, &comp).await; + } else { + self.handle_pagination(&ctx, &comp).await; + } } _ => {} } @@ -1528,6 +1591,80 @@ impl EventHandler for Handler { // --- Slash command & interaction handlers --- impl Handler { + fn shared_command_service(&self) -> CommandService { + CommandService::new(self.router.pool().clone(), self.dispatcher.clone()) + } + + fn shared_command_context(channel_id: ChannelId) -> CommandContext { + CommandContext::new("discord", channel_id.to_string(), true) + } + + async fn shared_command_admission( + &self, + ctx: &Context, + channel_id: ChannelId, + guild_id: Option, + user_id: UserId, + user_is_bot: bool, + ) -> Result<(), &'static str> { + if user_is_bot { + return Err("🤖 Bots cannot use this command."); + } + if is_denied_user( + false, + self.allow_all_users, + &self.allowed_users, + user_id.get(), + ) { + return Err("🚫 You are not allowed to use this bot."); + } + + let is_dm = guild_id.is_none(); + let surface_allowed = if is_dm { + discord_command_surface_allowed(true, self.allow_dm, false, false) + } else { + match channel_id.to_channel(&ctx.http).await { + Ok(serenity::model::channel::Channel::Guild(channel)) => { + let in_allowed_channel = self.allow_all_channels + || self.allowed_channels.contains(&channel_id.get()); + let (in_allowed_thread, _) = detect_thread( + channel.thread_metadata.is_some(), + channel.parent_id.map(|id| id.get()), + channel.owner_id.map(|id| id.get()), + ctx.cache.current_user().id.get(), + &self.allowed_channels, + self.allow_all_channels, + in_allowed_channel, + ); + discord_command_surface_allowed( + false, + self.allow_dm, + in_allowed_channel, + in_allowed_thread, + ) + } + _ => false, + } + }; + if !surface_allowed { + return Err("⚠️ Run this command inside an allowed Discord channel, thread, or DM."); + } + + if !self + .router + .gate_incoming( + "discord", + &channel_id.to_string(), + is_dm, + &user_id.to_string(), + ) + .is_allowed() + { + return Err("🚫 You are not allowed to use this bot."); + } + Ok(()) + } + /// Build a Discord select menu from ACP configOptions with the given category. /// Paginates options in pages of 25 (Discord limit). The current selection is /// always placed first so it appears on page 0. @@ -1636,15 +1773,9 @@ impl Handler { .iter() .find(|o| o.category.as_deref() == Some(category))?; let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE); - let page = match page { - Some(p) => p.min(total_pages.saturating_sub(1)), - None => opt - .options - .iter() - .position(|o| o.value == opt.current_value) - .map(|i| i / SELECT_MENU_PAGE_SIZE) - .unwrap_or(0), - }; + // build_config_select moves the current value to index zero, so a new + // interaction must start on page zero regardless of its original index. + let page = page.unwrap_or(0).min(total_pages.saturating_sub(1)); let select = Self::build_config_select(options, category, page)?; let mut rows = vec![CreateActionRow::SelectMenu(select)]; @@ -1658,28 +1789,42 @@ impl Handler { &self, ctx: &Context, cmd: &serenity::model::application::CommandInteraction, - category: &str, + category: ConfigCategory, label: &str, ) { - let thread_key = format!("discord:{}", cmd.channel_id.get()); - let config_options = self.router.pool().get_config_options(&thread_key).await; - - let response = match Self::build_config_components(&config_options, category, None) { - Some(rows) => CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("🔧 Select a {label}:")) - .components(rows) - .ephemeral(true), - ), - None => CreateInteractionResponse::Message( + let context = Self::shared_command_context(cmd.channel_id); + let result = self + .shared_command_service() + .execute(CoreCommand::ListConfig(category), &context) + .await; + let response = match &result { + CommandResult::ConfigOptions { options, .. } => { + match Self::build_config_components(options, category.as_str(), None) { + Some(rows) => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("🔧 Select a {label}:")) + .components(rows) + .ephemeral(true), + ), + None => CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(render_text_result(&result)) + .ephemeral(true), + ), + } + } + _ => CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content(format!("⚠️ No {label} options available. Start a conversation first by @mentioning the bot.")) + .content(render_text_result(&result)) .ephemeral(true), ), }; - if let Err(e) = cmd.create_response(&ctx.http, response).await { - tracing::error!(error = %e, category, "failed to respond to slash command"); + if cmd.create_response(&ctx.http, response).await.is_err() { + tracing::error!( + category = category.as_str(), + "failed to respond to config command" + ); } } @@ -1688,102 +1833,78 @@ impl Handler { ctx: &Context, cmd: &serenity::model::application::CommandInteraction, ) { - let thread_key = format!("discord:{}", cmd.channel_id.get()); - - if !self.router.pool().has_active_session(&thread_key).await { - let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("⚠️ No active session. Start a conversation first by @mentioning the bot.") - .ephemeral(true), - ); - if let Err(e) = cmd.create_response(&ctx.http, response).await { - tracing::error!(error = %e, "failed to respond to /usage command"); - } - return; - } - // The ACP round-trip can exceed Discord's 3-second interaction // deadline — acknowledge with a deferred ephemeral response first. let defer = CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new().ephemeral(true)); - if let Err(e) = cmd.create_response(&ctx.http, defer).await { - tracing::error!(error = %e, "failed to defer /usage response"); + if cmd.create_response(&ctx.http, defer).await.is_err() { + tracing::error!("failed to defer /usage response"); return; } - let followup = match self.router.pool().get_usage(&thread_key).await { - Ok(report) => { - let (content, embed) = build_usage_reply(&report); + let context = Self::shared_command_context(cmd.channel_id); + let result = self + .shared_command_service() + .execute(CoreCommand::Usage, &context) + .await; + let followup = match &result { + CommandResult::Usage(report) => { + let (content, embed) = build_usage_reply(report); CreateInteractionResponseFollowup::new() .content(content) .embed(embed) .ephemeral(true) } - Err(e) => CreateInteractionResponseFollowup::new() - .content(format!("⚠️ {e}")) + _ => CreateInteractionResponseFollowup::new() + .content(render_text_result(&result)) .ephemeral(true), }; - if let Err(e) = cmd.create_followup(&ctx.http, followup).await { - tracing::error!(error = %e, "failed to send /usage followup"); + if cmd.create_followup(&ctx.http, followup).await.is_err() { + tracing::error!("failed to send /usage followup"); } } - async fn handle_cancel_command( + async fn handle_control_command( &self, ctx: &Context, cmd: &serenity::model::application::CommandInteraction, + command: CoreCommand, ) { - let thread_key = format!("discord:{}", cmd.channel_id.get()); - let result = self.router.pool().cancel_session(&thread_key).await; - - let msg = match result { - Ok(()) => "🛑 Cancel signal sent.".to_string(), - Err(e) => format!("⚠️ {e}"), - }; - + let command_name = command.name(); + let context = Self::shared_command_context(cmd.channel_id); + let result = self + .shared_command_service() + .execute(command, &context) + .await; let response = CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() - .content(msg) + .content(render_text_result(&result)) .ephemeral(true), ); - if let Err(e) = cmd.create_response(&ctx.http, response).await { - tracing::error!(error = %e, "failed to respond to /cancel command"); + if cmd.create_response(&ctx.http, response).await.is_err() { + tracing::error!( + command = command_name.as_str(), + "failed to respond to control command" + ); } } - async fn handle_cancel_all_command( + async fn handle_cancel_command( &self, ctx: &Context, cmd: &serenity::model::application::CommandInteraction, ) { - // /cancel-all is the nuclear escape hatch: stop the in-flight turn AND clear - // every lane's buffer in this thread, so a human can intervene from a clean slate. - let session_key = format!("discord:{}", cmd.channel_id.get()); - let dropped = self - .dispatcher - .cancel_buffered_thread("discord", &cmd.channel_id.get().to_string()); - - let cancel_result = self.router.pool().cancel_session(&session_key).await; - - // Buffer count is approximate (sweep races with new arrivals) so we surface - // a binary "cleared / nothing" signal rather than a misleading exact number. - let msg = match (cancel_result, dropped) { - (Ok(()), 0) => "🛑 Cancel signal sent.".to_string(), - (Ok(()), _) => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(), - (Err(_), 0) => { - "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string() - } - (Err(_), _) => "🛑 Buffered messages cleared. No active session to cancel.".to_string(), - }; + self.handle_control_command(ctx, cmd, CoreCommand::Cancel) + .await; + } - let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(msg) - .ephemeral(true), - ); - if let Err(e) = cmd.create_response(&ctx.http, response).await { - tracing::error!(error = %e, "failed to respond to /cancel-all command"); - } + async fn handle_cancel_all_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + self.handle_control_command(ctx, cmd, CoreCommand::CancelAll) + .await; } async fn handle_reset_command( @@ -1791,37 +1912,8 @@ impl Handler { ctx: &Context, cmd: &serenity::model::application::CommandInteraction, ) { - // /reset clears every lane's buffer in this thread and tears down the shared - // ACP session — the next message in the thread starts a fresh conversation. - let session_key = format!("discord:{}", cmd.channel_id.get()); - let dropped = self - .dispatcher - .cancel_buffered_thread("discord", &cmd.channel_id.get().to_string()); - - let result = self.router.pool().reset_session(&session_key).await; - - let msg = match result { - Ok(()) if dropped > 0 => { - format!("🔄 Session reset. Dropped {dropped} buffered message(s). Start a new conversation!") - } - Ok(()) => "🔄 Session reset. Start a new conversation!".to_string(), - Err(_) if dropped > 0 => { - format!("🔄 Dropped {dropped} buffered message(s). No active session to reset.") - } - Err(_) => { - "⚠️ No active session to reset. Start a conversation first by @mentioning the bot." - .to_string() - } - }; - - let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(msg) - .ephemeral(true), - ); - if let Err(e) = cmd.create_response(&ctx.http, response).await { - tracing::error!(error = %e, "failed to respond to /reset command"); - } + self.handle_control_command(ctx, cmd, CoreCommand::Reset) + .await; } async fn handle_remind_command( @@ -2452,53 +2544,26 @@ impl Handler { .data .custom_id .strip_prefix("acp_config_") - .unwrap_or("") - .to_string(); - - if config_id.is_empty() { - return; - } - + .unwrap_or(""); let selected_value = match &comp.data.kind { - ComponentInteractionDataKind::StringSelect { values } => match values.first() { - Some(v) => v.clone(), - None => return, - }, - _ => return, + ComponentInteractionDataKind::StringSelect { values } => { + values.first().map(String::as_str).unwrap_or("") + } + _ => "", }; - - let thread_key = format!("discord:{}", comp.channel_id.get()); - + let context = Self::shared_command_context(comp.channel_id); let result = self - .router - .pool() - .set_config_option(&thread_key, &config_id, &selected_value) + .shared_command_service() + .set_config_value(&context, config_id, selected_value) .await; - - let response_msg = match result { - Ok(updated_options) => { - let display_name = updated_options - .iter() - .find(|o| o.id == config_id) - .and_then(|o| o.options.iter().find(|v| v.value == selected_value)) - .map(|v| v.name.as_str()) - .unwrap_or(&selected_value); - format!("✅ Switched to **{}**", display_name) - } - Err(e) => { - tracing::error!(error = %e, "failed to set config option"); - format!("❌ Failed to switch: {}", e) - } - }; - let response = CreateInteractionResponse::UpdateMessage( CreateInteractionResponseMessage::new() - .content(response_msg) + .content(render_text_result(&result)) .components(vec![]), ); - if let Err(e) = comp.create_response(&ctx.http, response).await { - tracing::error!(error = %e, "failed to respond to config select"); + if comp.create_response(&ctx.http, response).await.is_err() { + tracing::error!("failed to respond to config select"); } } @@ -2507,39 +2572,56 @@ impl Handler { ctx: &Context, comp: &serenity::model::application::ComponentInteraction, ) { - // Parse custom_id format: acp_pg:{category}:{page} let parts: Vec<&str> = comp.data.custom_id.splitn(3, ':').collect(); - let (category, page) = match parts.as_slice() { - [_, cat, pg] => match pg.parse::() { - Ok(p) => (*cat, p), - Err(_) => return, - }, - _ => return, + let parsed = match parts.as_slice() { + [_, "model", page] => page + .parse::() + .ok() + .map(|page| (ConfigCategory::Model, page)), + [_, "agent", page] => page + .parse::() + .ok() + .map(|page| (ConfigCategory::Agent, page)), + _ => None, }; - // Only allow known config categories. - if !matches!(category, "model" | "agent") { - return; - } - - let thread_key = format!("discord:{}", comp.channel_id.get()); - let config_options = self.router.pool().get_config_options(&thread_key).await; - - let response = match Self::build_config_components(&config_options, category, Some(page)) { - Some(rows) => CreateInteractionResponse::UpdateMessage( - CreateInteractionResponseMessage::new() - .content(format!("🔧 Select a {category}:")) - .components(rows), - ), - None => CreateInteractionResponse::UpdateMessage( + let response = if let Some((category, page)) = parsed { + let context = Self::shared_command_context(comp.channel_id); + let result = self + .shared_command_service() + .execute(CoreCommand::ListConfig(category), &context) + .await; + match &result { + CommandResult::ConfigOptions { options, .. } => { + match Self::build_config_components(options, category.as_str(), Some(page)) { + Some(rows) => CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .content(format!("🔧 Select a {}:", category.as_str())) + .components(rows), + ), + None => CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .content(render_text_result(&result)) + .components(vec![]), + ), + } + } + _ => CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .content(render_text_result(&result)) + .components(vec![]), + ), + } + } else { + CreateInteractionResponse::UpdateMessage( CreateInteractionResponseMessage::new() - .content(format!("⚠️ No {category} options available.")) + .content("⚠️ This configuration menu is no longer valid.") .components(vec![]), - ), + ) }; - if let Err(e) = comp.create_response(&ctx.http, response).await { - tracing::error!(error = %e, category, "failed to respond to pagination"); + if comp.create_response(&ctx.http, response).await.is_err() { + tracing::error!("failed to respond to config pagination"); } } } @@ -2622,6 +2704,7 @@ fn discord_msg_ref(msg: &Message) -> MessageRef { channel_id: msg.channel_id.get().to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: msg.id.to_string(), @@ -2920,6 +3003,7 @@ async fn get_or_create_thread( channel_id: msg.channel_id.get().to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }); } @@ -2931,6 +3015,7 @@ async fn get_or_create_thread( channel_id: msg.channel_id.get().to_string(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }; let trigger_ref = discord_msg_ref(msg); @@ -2965,6 +3050,7 @@ async fn get_or_create_thread( channel_id: existing.id.to_string(), thread_id: None, parent_id: Some(msg.channel_id.get().to_string()), + persistent_conversation: None, origin_event_id: None, }) } @@ -2984,8 +3070,10 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { msg.contains("160004") || msg.contains("already been created") } -static ROLE_MENTION_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap()); +static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"<@&\d+>") + .unwrap_or_else(|error| panic!("invalid role mention regex: {error}")) +}); fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet) -> String { // 1. Strip the bot's own trigger mention @@ -3081,6 +3169,19 @@ fn build_sender_context( /// https://docs.discord.com/developers/resources/channel#channel-object /// - Thread Metadata ("thread-specific fields not needed by other channels"): /// https://docs.discord.com/developers/resources/channel#thread-metadata-object +fn discord_command_surface_allowed( + is_dm: bool, + allow_dm: bool, + in_allowed_channel: bool, + in_allowed_thread: bool, +) -> bool { + if is_dm { + allow_dm + } else { + in_allowed_channel || in_allowed_thread + } +} + fn detect_thread( has_thread_metadata: bool, parent_id: Option, @@ -3223,8 +3324,10 @@ fn turn_limit_warning_present(messages: &[(bool, &str)]) -> bool { /// Auth CLIs like `codex` emit these for terminal styling, but they render as /// garbage in Discord messages. fn strip_ansi_codes(s: &str) -> String { - static ANSI_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\([A-Z]").unwrap()); + static ANSI_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\([A-Z]") + .unwrap_or_else(|error| panic!("invalid ANSI regex: {error}")) + }); ANSI_RE.replace_all(s, "").into_owned() } @@ -3233,8 +3336,10 @@ fn strip_ansi_codes(s: &str) -> String { /// node is adjacent to a Text node, causing `accounthttps://...` rendering. /// This inserts a newline before any URL that immediately follows a non-whitespace char. fn ensure_url_separation(s: &str) -> String { - static URL_RE: LazyLock = - LazyLock::new(|| regex::Regex::new(r"(?P\S)(?Phttps?://)").unwrap()); + static URL_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"(?P\S)(?Phttps?://)") + .unwrap_or_else(|error| panic!("invalid URL separation regex: {error}")) + }); URL_RE.replace_all(s, "${prev}\n${url}").into_owned() } @@ -3304,6 +3409,33 @@ mod tests { assert!(out.ends_with('…')); } + #[test] + fn config_components_keep_current_value_on_initial_page() { + let current_value = "value-29"; + let options = vec![ConfigOption { + id: "model".into(), + name: "Model".into(), + description: None, + category: Some("model".into()), + option_type: "enum".into(), + current_value: current_value.into(), + options: (0..30) + .map(|index| crate::acp::protocol::ConfigOptionValue { + value: format!("value-{index}"), + name: format!("Model {index}"), + description: None, + }) + .collect(), + }]; + let Some(rows) = Handler::build_config_components(&options, "model", None) else { + panic!("model components must be available"); + }; + let Ok(serialized) = serde_json::to_string(&rows) else { + panic!("model components must serialize"); + }; + assert!(serialized.contains(current_value)); + } + // --- format_usage_report tests (/usage slash command) --- fn usage_breakdown() -> crate::acp::protocol::UsageBreakdown { @@ -4204,6 +4336,7 @@ mod tests { channel_id: "111".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: None, }; assert_eq!(DiscordAdapter::resolve_channel(&ch), "111"); @@ -4216,6 +4349,7 @@ mod tests { channel_id: "111".into(), thread_id: Some("222".into()), parent_id: None, + persistent_conversation: None, origin_event_id: None, }; assert_eq!(DiscordAdapter::resolve_channel(&ch), "222"); @@ -4413,6 +4547,15 @@ mod tests { assert!(!is_denied_user(false, false, &allowed, 100)); } + #[test] + fn shared_command_scope_matches_discord_dm_channel_and_thread_policy() { + assert!(discord_command_surface_allowed(true, true, false, false)); + assert!(!discord_command_surface_allowed(true, false, true, true)); + assert!(discord_command_surface_allowed(false, false, true, false)); + assert!(discord_command_surface_allowed(false, false, false, true)); + assert!(!discord_command_surface_allowed(false, true, false, false)); + } + /// DMs are treated as implicit @mention — should_process_user_message /// is never called for DMs (the `!is_dm` guard skips it). /// This test verifies the Involved mode would reject a non-thread, diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs index 64ba68917..15ca1ed8d 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use tracing::{debug, error, info, info_span, warn}; use crate::acp::ContentBlock; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef}; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, StatusBackend}; use crate::config::ReactionsConfig; use crate::error_display::format_user_error; use crate::reactions::StatusReactionController; @@ -286,19 +286,33 @@ impl Dispatcher { /// Build the dispatcher key for a (platform, thread, sender) tuple. /// + /// Every segment is byte-length-prefixed because native IDs (notably Teams + /// conversation IDs) may contain `:`. A delimiter-only key can alias + /// `(thread = "a", sender = "b:c")` with `(thread = "a:b", sender = "c")`, + /// causing cross-thread buffering or cancellation. + /// /// In `Thread` mode the sender is ignored; in `Lane` mode the sender is appended /// so each (thread, sender) pair gets its own mpsc and consumer. /// /// Note: this is the *dispatcher* key, not the *session pool* key. Session pool keys - /// are always `:` regardless of grouping (the ACP session is + /// remain `:` regardless of grouping (the ACP session is /// shared per-thread by design). pub fn key(&self, platform: &str, thread_id: &str, sender_id: &str) -> String { + let base = Self::thread_key_prefix(platform, thread_id); match self.grouping { - BatchGrouping::Thread => format!("{platform}:{thread_id}"), - BatchGrouping::Lane => format!("{platform}:{thread_id}:{sender_id}"), + BatchGrouping::Thread => base, + BatchGrouping::Lane => format!("{base}{}:{sender_id}", sender_id.len()), } } + fn thread_key_prefix(platform: &str, thread_id: &str) -> String { + format!( + "{}:{platform}{}:{thread_id}", + platform.len(), + thread_id.len() + ) + } + /// Build the shared session pool key for a routed channel. /// /// Unlike dispatcher keys, session keys never include sender identity. @@ -340,7 +354,10 @@ impl Dispatcher { let (tx, my_generation) = { // SAFETY: no .await while this guard is held — guard drops at end of block. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // Proactive stale-entry cleanup: if the consumer has exited (idle // timeout or unexpected), remove the entry so `or_insert_with` @@ -385,7 +402,10 @@ impl Dispatcher { // retry acquisition below. { // SAFETY: no .await while this guard is held. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); Self::try_evict_locked(&mut map, &thread_key, my_generation); } let failed_msg = e.0; @@ -395,7 +415,10 @@ impl Dispatcher { let retry_g = self.next_generation.fetch_add(1, Ordering::Relaxed); let (retry_tx, retry_gen) = { // SAFETY: no .await while this guard is held — guard drops at end of block. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let entry = map.entry(thread_key.clone()).or_insert_with(|| { let (tx, rx) = tokio::sync::mpsc::channel(cap); let consumer = tokio::spawn(consumer_loop( @@ -423,7 +446,10 @@ impl Dispatcher { // Retry also failed — truly unexpected. Surface error. { // SAFETY: no .await while this guard is held. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); Self::try_evict_locked(&mut map, &thread_key, retry_gen); } let failed_msg = e2.0; @@ -452,21 +478,23 @@ impl Dispatcher { /// regardless of grouping, and abort each consumer (§2.5 / §4.4). Returns /// the total number of buffered messages discarded across all lanes. /// - /// Matches both Thread keys (`:`) and Lane keys - /// (`::`). Used by `/reset` and - /// `/cancel-all` to clear the entire thread, not just one lane. + /// Matches the exact length-prefixed platform/thread prefix for both + /// Thread and Lane grouping. Used by `/reset` and `/cancel-all` to clear + /// the entire thread, not just one lane. /// /// Disjoint from SendError recovery: removal happens *before* abort, so any /// fresh `submit` after this returns lands on a lazily-constructed new handle /// instead of observing `SendError`. pub fn cancel_buffered_thread(&self, platform: &str, thread_id: &str) -> usize { - let prefix = format!("{platform}:{thread_id}"); - let lane_prefix = format!("{prefix}:"); + let prefix = Self::thread_key_prefix(platform, thread_id); // SAFETY: no .await while this guard is held — function is sync. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let keys: Vec = map .keys() - .filter(|k| k.as_str() == prefix || k.starts_with(&lane_prefix)) + .filter(|key| key.starts_with(&prefix)) .cloned() .collect(); let mut dropped = 0; @@ -504,7 +532,10 @@ impl Dispatcher { /// receive a second `submit()`. Returns the number of entries swept. pub fn sweep_stale(&self) -> usize { // SAFETY: no .await while this guard is held — function is sync. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let before = map.len(); map.retain(|_, handle| !handle.consumer.is_finished()); before - map.len() @@ -513,7 +544,10 @@ impl Dispatcher { /// Log buffered-message counts and drop all handles (called on SIGTERM). pub fn shutdown(&self) { // SAFETY: no .await while this guard is held — function is sync. - let mut map = self.per_thread.lock().unwrap(); + let mut map = self + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); for (thread_id, handle) in map.iter() { let pending = handle.pending_count(); if pending > 0 { @@ -594,8 +628,11 @@ async fn consumer_loop( } } - // §2.6: read the freshest snapshot in the batch (batch is non-empty). - let bot_present = batch.last().unwrap().other_bot_present; + // §2.6: read the freshest snapshot in the batch. + let Some(last_message) = batch.last() else { + continue; + }; + let bot_present = last_message.other_bot_present; dispatch_batch( &thread_key, @@ -625,14 +662,22 @@ async fn dispatch_batch( let batch_size = batch.len(); let session_key = Dispatcher::session_key(thread_channel); - // Apply 👀 reaction to every message in the batch before dispatch (§6.7). - // Skip when assistant status API is active — uses - // assistant.threads.setStatus instead of emoji reactions. - let assistant_status = adapter.uses_assistant_status(); - if !assistant_status { - let queued_emoji = &target.reactions_config().emojis.queued; - for msg in batch.iter() { - let _ = adapter.add_reaction(&msg.trigger_msg, queued_emoji).await; + let Some(trigger_msg) = batch.last().map(|msg| msg.trigger_msg.clone()) else { + return; + }; + let reactions_config = target.reactions_config().clone(); + + // Apply a permanent 👀 receipt marker to every event in the batch, as + // required by turn-boundary-batching ADR §6.7. The progress controller + // below is intentionally separate and anchors only on the final event. + let capabilities = adapter.capabilities(&thread_channel.platform); + let reaction_status = capabilities.status_backend == StatusBackend::Reactions; + let receipt_reactions = reactions_config.enabled && capabilities.supports_reactions; + if receipt_reactions { + for msg in &batch { + let _ = adapter + .add_reaction(&msg.trigger_msg, &reactions_config.emojis.queued) + .await; } } @@ -648,8 +693,6 @@ async fn dispatch_batch( // batch attributes to the most recent sender; None for non-Slack/bot turns. let recipient: Option<(String, String)> = batch.last().and_then(|m| m.recipient.clone()); - // Anchor reactions on the last message in the batch (before consuming). - let trigger_msg = batch.last().unwrap().trigger_msg.clone(); let dispatch_channel = ChannelRef { // Reply correlation is event-scoped, but the dispatcher consumer is // thread-scoped. Rebuild the per-dispatch channel from the stable @@ -757,7 +800,6 @@ async fn dispatch_batch( } let packed_block_count = content_blocks.len(); - let reactions_config = target.reactions_config().clone(); let reactions = Arc::new(StatusReactionController::new( reactions_config.enabled, adapter.clone(), @@ -765,7 +807,8 @@ async fn dispatch_batch( reactions_config.emojis.clone(), reactions_config.timing.clone(), )); - // 👀 already applied above; skip set_queued() to avoid double-reaction. + // 👀 receipt markers are intentionally outside this controller and remain + // visible after the turn completes (turn-boundary-batching ADR §6.7). let result = target .stream_prompt_blocks( @@ -779,9 +822,9 @@ async fn dispatch_batch( ) .await; - // In assistant status mode, all status is conveyed via - // assistant.threads.setStatus — skip emoji reactions entirely. - if !assistant_status { + // Finalize only the reactions backend; other status lifecycles are handled + // independently by stream_prompt_blocks or their platform adapter. + if reaction_status { match &result { Ok(()) => reactions.set_done().await, Err(_) => reactions.set_error().await, @@ -802,9 +845,11 @@ async fn dispatch_batch( } if let Err(ref e) = result { - let _ = adapter - .send_message(&dispatch_channel, &format!("⚠️ {e}")) - .await; + if !crate::progressive::is_ambiguous_delivery(e) { + let _ = adapter + .send_message(&dispatch_channel, &format!("⚠️ {e}")) + .await; + } } let agent_dispatch_ms = dispatch_start.elapsed().as_millis(); @@ -1172,7 +1217,7 @@ mod tests { map.insert("t".into(), dummy_handle(8)); assert!(!Dispatcher::try_evict_locked(&mut map, "t", 7)); assert_eq!(map.len(), 1); - assert_eq!(map.get("t").unwrap().generation, 8); + assert_eq!(map.get("t").map(|handle| handle.generation), Some(8)); } #[tokio::test] @@ -1219,17 +1264,27 @@ mod tests { #[tokio::test] async fn key_per_thread_ignores_sender() { let d = make_dispatcher(BatchGrouping::Thread); - assert_eq!(d.key("discord", "T1", "userA"), "discord:T1"); - assert_eq!(d.key("discord", "T1", "userB"), "discord:T1"); + assert_eq!( + d.key("discord", "T1", "userA"), + d.key("discord", "T1", "userB") + ); + assert_ne!( + d.key("discord", "T1", "userA"), + d.key("slack", "T1", "userA") + ); } #[tokio::test] - async fn key_per_lane_includes_sender() { + async fn key_per_lane_is_collision_safe_for_native_ids() { let d = make_dispatcher(BatchGrouping::Lane); - assert_eq!(d.key("discord", "T1", "userA"), "discord:T1:userA"); - assert_eq!(d.key("discord", "T1", "userB"), "discord:T1:userB"); - // Different threads remain distinct. - assert_eq!(d.key("slack", "T2", "userA"), "slack:T2:userA"); + assert_ne!( + d.key("discord", "T1", "userA"), + d.key("discord", "T1", "userB") + ); + assert_ne!( + d.key("teams", "19", "user:x"), + d.key("teams", "19:user", "x") + ); } fn insert_dummy_handle(d: &Dispatcher, key: &str) { @@ -1242,45 +1297,66 @@ mod tests { channel_id: "c".into(), adapter_kind: "discord".into(), }; - d.per_thread.lock().unwrap().insert(key.to_string(), handle); + d.per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key.to_string(), handle); } #[tokio::test] async fn cancel_buffered_thread_drops_per_thread_key() { let d = make_dispatcher(BatchGrouping::Thread); - insert_dummy_handle(&d, "discord:T1"); - insert_dummy_handle(&d, "discord:T2"); // different thread, must survive - assert_eq!(d.cancel_buffered_thread("discord", "T1"), 0); // no buffered msgs - let map = d.per_thread.lock().unwrap(); - assert!(!map.contains_key("discord:T1")); - assert!(map.contains_key("discord:T2")); + let t1 = d.key("discord", "T1", "ignored"); + let t2 = d.key("discord", "T2", "ignored"); + insert_dummy_handle(&d, &t1); + insert_dummy_handle(&d, &t2); + assert_eq!(d.cancel_buffered_thread("discord", "T1"), 0); + let map = d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert!(!map.contains_key(&t1)); + assert!(map.contains_key(&t2)); } #[tokio::test] async fn cancel_buffered_thread_drops_all_lanes() { let d = make_dispatcher(BatchGrouping::Lane); - insert_dummy_handle(&d, "discord:T1:userA"); - insert_dummy_handle(&d, "discord:T1:userB"); - insert_dummy_handle(&d, "discord:T2:userA"); // different thread - insert_dummy_handle(&d, "slack:T1:userA"); // different platform + let t1a = d.key("discord", "T1", "userA"); + let t1b = d.key("discord", "T1", "userB"); + let t2a = d.key("discord", "T2", "userA"); + let slack = d.key("slack", "T1", "userA"); + for key in [&t1a, &t1b, &t2a, &slack] { + insert_dummy_handle(&d, key); + } d.cancel_buffered_thread("discord", "T1"); - let map = d.per_thread.lock().unwrap(); - assert!(!map.contains_key("discord:T1:userA")); - assert!(!map.contains_key("discord:T1:userB")); - assert!(map.contains_key("discord:T2:userA")); - assert!(map.contains_key("slack:T1:userA")); + let map = d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert!(!map.contains_key(&t1a)); + assert!(!map.contains_key(&t1b)); + assert!(map.contains_key(&t2a)); + assert!(map.contains_key(&slack)); } #[tokio::test] - async fn cancel_buffered_thread_does_not_match_thread_id_prefix() { - // T1 must not match T10 / T11 (substring trap). + async fn cancel_buffered_thread_does_not_cross_colon_or_prefix_boundaries() { let d = make_dispatcher(BatchGrouping::Lane); - insert_dummy_handle(&d, "discord:T1:userA"); - insert_dummy_handle(&d, "discord:T10:userA"); - d.cancel_buffered_thread("discord", "T1"); - let map = d.per_thread.lock().unwrap(); - assert!(!map.contains_key("discord:T1:userA")); - assert!(map.contains_key("discord:T10:userA")); + let target = d.key("teams", "19", "user:x"); + let colon_thread = d.key("teams", "19:user", "x"); + let prefix_thread = d.key("teams", "190", "user:x"); + for key in [&target, &colon_thread, &prefix_thread] { + insert_dummy_handle(&d, key); + } + d.cancel_buffered_thread("teams", "19"); + let map = d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert!(!map.contains_key(&target)); + assert!(map.contains_key(&colon_thread)); + assert!(map.contains_key(&prefix_thread)); } // Long-running consumer that parks until aborted — used by sweep_stale / @@ -1309,7 +1385,11 @@ mod tests { tokio::time::sleep(Duration::from_millis(10)).await; let swept = d.sweep_stale(); assert_eq!(swept, 2); - assert!(d.per_thread.lock().unwrap().is_empty()); + assert!(d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty()); } #[tokio::test] @@ -1318,12 +1398,19 @@ mod tests { let abort = { let h = alive_consumer_handle(); let a = h.consumer.abort_handle(); - d.per_thread.lock().unwrap().insert("alive".into(), h); + d.per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert("alive".into(), h); a }; let swept = d.sweep_stale(); assert_eq!(swept, 0); - assert!(d.per_thread.lock().unwrap().contains_key("alive")); + assert!(d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains_key("alive")); // Cleanup so the parked task doesn't linger across tests. abort.abort(); } @@ -1335,7 +1422,11 @@ mod tests { insert_dummy_handle(&d, "k2"); insert_dummy_handle(&d, "k3"); d.shutdown(); - assert!(d.per_thread.lock().unwrap().is_empty()); + assert!(d + .per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_empty()); } #[tokio::test] @@ -1344,7 +1435,10 @@ mod tests { let abort = { let h = alive_consumer_handle(); let a = h.consumer.abort_handle(); - d.per_thread.lock().unwrap().insert("k".into(), h); + d.per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert("k".into(), h); a }; d.shutdown(); @@ -1392,7 +1486,10 @@ mod tests { } fn calls(&self) -> Vec { - self.calls.lock().unwrap().clone() + self.calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() } } @@ -1415,7 +1512,12 @@ mod tests { _session_key: &str, _working_dir: Option<&str>, ) -> Result { - if let Some(msg) = self.ensure_err.lock().unwrap().take() { + if let Some(msg) = self + .ensure_err + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { return Err(anyhow::anyhow!(msg)); } Ok(true) @@ -1433,22 +1535,63 @@ mod tests { other_bot_present: bool, _recipient: Option<(String, String)>, ) -> Result<()> { - self.calls.lock().unwrap().push(RecordedDispatch { - block_count: content_blocks.len(), - other_bot_present, - dispatch_channel: thread_channel.clone(), - }); - if let Some(msg) = self.stream_err.lock().unwrap().take() { + self.calls + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(RecordedDispatch { + block_count: content_blocks.len(), + other_bot_present, + dispatch_channel: thread_channel.clone(), + }); + if let Some(msg) = self + .stream_err + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { return Err(anyhow::anyhow!(msg)); } Ok(()) } } - /// Mock `ChatAdapter` — every method is a no-op success. The dispatch loop - /// invokes `add_reaction` (queued 👀), `platform`, and on the error path - /// `send_message`; nothing else needs real behavior here. - struct MockChatAdapter; + /// Mock `ChatAdapter` — records reaction lifecycle calls and otherwise + /// returns success without touching a platform API. + struct MockChatAdapter { + reaction_events: Mutex>, + status_backend: StatusBackend, + supports_reactions: bool, + } + + impl Default for MockChatAdapter { + fn default() -> Self { + Self { + reaction_events: Mutex::new(Vec::new()), + status_backend: StatusBackend::Reactions, + supports_reactions: true, + } + } + } + + impl MockChatAdapter { + fn message_status_with_receipts() -> Self { + Self { + status_backend: StatusBackend::Message, + supports_reactions: true, + ..Self::default() + } + } + + fn reaction_events_mut(&self) -> std::sync::MutexGuard<'_, Vec<(String, String, String)>> { + self.reaction_events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn reaction_events(&self) -> Vec<(String, String, String)> { + self.reaction_events_mut().clone() + } + } #[async_trait] impl ChatAdapter for MockChatAdapter { @@ -1459,6 +1602,14 @@ mod tests { 2000 } + fn capabilities(&self, _platform: &str) -> crate::adapter::AdapterCapabilities { + crate::adapter::AdapterCapabilities { + supports_reactions: self.supports_reactions, + status_backend: self.status_backend, + ..crate::adapter::AdapterCapabilities::default() + } + } + async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result { Ok(MessageRef { channel: channel.clone(), @@ -1475,10 +1626,17 @@ mod tests { Ok(channel.clone()) } - async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.reaction_events_mut() + .push(("add".into(), msg.message_id.clone(), emoji.into())); Ok(()) } - async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.reaction_events_mut().push(( + "remove".into(), + msg.message_id.clone(), + emoji.into(), + )); Ok(()) } fn use_streaming(&self, _other_bot_present: bool) -> bool { @@ -1492,6 +1650,7 @@ mod tests { channel_id: thread.into(), thread_id: Some(thread.into()), parent_id: None, + persistent_conversation: None, origin_event_id: None, } } @@ -1523,10 +1682,10 @@ mod tests { ) -> Vec { let mock = Arc::new(MockDispatchTarget::new()); let target: Arc = mock.clone(); - let adapter: Arc = Arc::new(MockChatAdapter); + let adapter: Arc = Arc::new(MockChatAdapter::default()); let (tx, rx) = tokio::sync::mpsc::channel::(msgs.len().max(1)); for m in msgs { - tx.send(m).await.unwrap(); + assert!(tx.send(m).await.is_ok()); } drop(tx); @@ -1545,6 +1704,64 @@ mod tests { mock.calls() } + #[tokio::test] + async fn dispatch_preserves_batch_receipts_and_anchors_progress_on_last_event() { + let mock = Arc::new(MockDispatchTarget::new()); + let target: Arc = mock; + let recording = Arc::new(MockChatAdapter::default()); + let adapter: Arc = recording.clone(); + + dispatch_batch( + "mock:T", + &make_channel("T"), + &target, + &adapter, + vec![make_msg("first", 10), make_msg("last", 10)], + false, + ) + .await; + + let events = recording.reaction_events(); + assert_eq!(events.len(), 4, "unexpected reaction lifecycle: {events:?}"); + assert_eq!(events[0], ("add".into(), "m-first".into(), "👀".into())); + assert_eq!(events[1], ("add".into(), "m-last".into(), "👀".into())); + assert_eq!(events[2], ("add".into(), "m-last".into(), "🆗".into())); + assert_eq!(events[3].0, "add"); + assert_eq!(events[3].1, "m-last"); + assert!( + events + .iter() + .all(|(operation, _, emoji)| operation != "remove" || emoji != "👀"), + "batch receipt markers must remain visible after dispatch: {events:?}" + ); + } + + #[tokio::test] + async fn message_progress_backend_keeps_all_receipts_without_reaction_progress() { + let mock = Arc::new(MockDispatchTarget::new()); + let target: Arc = mock; + let recording = Arc::new(MockChatAdapter::message_status_with_receipts()); + let adapter: Arc = recording.clone(); + + dispatch_batch( + "mock:T", + &make_channel("T"), + &target, + &adapter, + vec![make_msg("first", 10), make_msg("last", 10)], + false, + ) + .await; + + assert_eq!( + recording.reaction_events(), + vec![ + ("add".into(), "m-first".into(), "👀".into()), + ("add".into(), "m-last".into(), "👀".into()), + ] + ); + } + #[tokio::test] async fn consumer_dispatches_single_message_as_one_batch() { let calls = run_consumer_with_messages(vec![make_msg("hi", 10)], 10, 24_000).await; @@ -1600,7 +1817,7 @@ mod tests { async fn consumer_dispatch_preserves_thread_route_while_refreshing_origin_event_id() { let mock = Arc::new(MockDispatchTarget::new()); let target: Arc = mock.clone(); - let adapter: Arc = Arc::new(MockChatAdapter); + let adapter: Arc = Arc::new(MockChatAdapter::default()); let (tx, rx) = tokio::sync::mpsc::channel::(1); let mut msg = make_msg("hi", 10); @@ -1609,9 +1826,10 @@ mod tests { channel_id: "parent-channel".into(), thread_id: None, parent_id: None, + persistent_conversation: None, origin_event_id: Some("evt-fresh".into()), }; - tx.send(msg).await.unwrap(); + assert!(tx.send(msg).await.is_ok()); drop(tx); consumer_loop( @@ -1621,6 +1839,7 @@ mod tests { channel_id: "topic-42".into(), thread_id: Some("topic-42".into()), parent_id: Some("parent-channel".into()), + persistent_conversation: None, origin_event_id: Some("evt-stale".into()), }, rx, @@ -1656,7 +1875,7 @@ mod tests { // "all senders dropped" branch. let mock = Arc::new(MockDispatchTarget::new()); let target: Arc = mock.clone(); - let adapter: Arc = Arc::new(MockChatAdapter); + let adapter: Arc = Arc::new(MockChatAdapter::default()); let (tx, rx) = tokio::sync::mpsc::channel::(1); let consumer = tokio::spawn(consumer_loop( "mock:T".into(), @@ -1694,7 +1913,7 @@ mod tests { BatchGrouping::Thread, DEFAULT_CONSUMER_IDLE_TIMEOUT, ); - let adapter: Arc = Arc::new(MockChatAdapter); + let adapter: Arc = Arc::new(MockChatAdapter::default()); let key = "mock:T".to_string(); let parked = { @@ -1709,7 +1928,10 @@ mod tests { channel_id: "T".into(), adapter_kind: "mock".into(), }; - d.per_thread.lock().unwrap().insert(key.clone(), handle); + d.per_thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key.clone(), handle); abort }; diff --git a/crates/openab-core/src/format.rs b/crates/openab-core/src/format.rs index 4fa1ce9e1..88a359077 100644 --- a/crates/openab-core/src/format.rs +++ b/crates/openab-core/src/format.rs @@ -1,209 +1,322 @@ +use std::fmt; use unicode_segmentation::UnicodeSegmentation; -/// Byte index after at most `max_chars` (>=1) Unicode scalar values — a last-resort -/// split used ONLY when a single grapheme cluster is itself wider than the target width. -/// It splits inside the cluster by codepoint (unavoidable: a cluster wider than the -/// whole limit cannot be both kept intact and fit) so every emitted chunk still honors -/// the caller's hard char limit. Guarantees forward progress (>=1 char). -fn codepoint_split_point(s: &str, max_chars: usize) -> usize { - s.char_indices() - .nth(max_chars.max(1)) - .map_or(s.len(), |(i, _)| i) +/// Internal measurement used by the final-content splitter. Wire capabilities +/// map to this type without collapsing byte-based limits into character counts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TextBudget { + Characters(usize), + Bytes(usize), + Utf16Bytes(usize), + Unlimited, } -/// Byte index at which to cut `s` so the prefix is at most `max_chars` Unicode scalar -/// values **without splitting a grapheme cluster** (emoji, ZWJ sequences, regional- -/// indicator flags, VS16, combining marks all stay whole). When `word_wrap` and the cut -/// would land mid-word, it backtracks to just after the last whitespace in the prefix so -/// words / CJK runs are not broken mid-token. Returns `0` when not even the first -/// grapheme fits in `max_chars` (the caller decides whether to flush or force it). -fn split_point(s: &str, max_chars: usize, word_wrap: bool) -> usize { - let mut chars = 0usize; +impl TextBudget { + fn max(self) -> Option { + match self { + Self::Characters(max) | Self::Bytes(max) | Self::Utf16Bytes(max) => Some(max), + Self::Unlimited => None, + } + } + + pub(crate) fn measure(self, value: &str) -> usize { + match self { + Self::Characters(_) => value.chars().count(), + Self::Bytes(_) => value.len(), + Self::Utf16Bytes(_) => value.encode_utf16().count().saturating_mul(2), + Self::Unlimited => 0, + } + } + + fn scalar_cost(self, value: char) -> usize { + match self { + Self::Characters(_) => 1, + Self::Bytes(_) => value.len_utf8(), + Self::Utf16Bytes(_) => value.len_utf16().saturating_mul(2), + Self::Unlimited => 0, + } + } + + fn unit(self) -> &'static str { + match self { + Self::Characters(_) => "characters", + Self::Bytes(_) => "bytes", + Self::Utf16Bytes(_) => "UTF-16 bytes", + Self::Unlimited => "unlimited", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SplitMessageError { + unit: &'static str, + max: usize, + required: usize, +} + +impl SplitMessageError { + fn new(budget: TextBudget, max: usize, required: usize) -> Self { + Self { + unit: budget.unit(), + max, + required, + } + } +} + +impl fmt::Display for SplitMessageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "message cannot be split within a {} budget of {} (smallest required unit costs {})", + self.unit, self.max, self.required + ) + } +} + +impl std::error::Error for SplitMessageError {} + +/// Last-resort scalar-boundary split used only when one extended grapheme is +/// wider than the whole budget. Returns an error when even one Unicode scalar +/// cannot fit, because emitting invalid UTF-8 or an oversized chunk is unsafe. +fn scalar_split_point( + value: &str, + max: usize, + budget: TextBudget, +) -> Result { + let mut used = 0usize; let mut byte = 0usize; - let mut last_ws_byte = 0usize; // byte index just past the last whitespace grapheme - for (start, g) in s.grapheme_indices(true) { - let g_chars = g.chars().count(); - if chars + g_chars > max_chars { + let mut first_cost = 0usize; + for (start, scalar) in value.char_indices() { + let cost = budget.scalar_cost(scalar); + if first_cost == 0 { + first_cost = cost; + } + if used.saturating_add(cost) > max { break; } - chars += g_chars; - byte = start + g.len(); - if g.chars().all(char::is_whitespace) { + used += cost; + byte = start + scalar.len_utf8(); + } + if byte == 0 && !value.is_empty() { + Err(SplitMessageError::new(budget, max, first_cost)) + } else { + Ok(byte) + } +} + +/// Byte index at which to cut `value` without splitting an extended grapheme. +/// When `word_wrap` is true, prefer the last whitespace boundary in the fitting +/// prefix. Returns zero when the first grapheme does not fit. +fn split_point(value: &str, max: usize, word_wrap: bool, budget: TextBudget) -> usize { + let mut used = 0usize; + let mut byte = 0usize; + let mut last_ws_byte = 0usize; + for (start, grapheme) in value.grapheme_indices(true) { + let cost = budget.measure(grapheme); + if used.saturating_add(cost) > max { + break; + } + used += cost; + byte = start + grapheme.len(); + if grapheme.chars().all(char::is_whitespace) { last_ws_byte = byte; } } - if word_wrap && byte < s.len() && last_ws_byte > 0 { + if word_wrap && byte < value.len() && last_ws_byte > 0 { return last_ws_byte; } byte } -/// Split text into chunks at line boundaries, each <= limit Unicode characters (UTF-8 safe). -/// Discord's message limit counts Unicode characters, not bytes. -/// -/// Fenced code blocks (``` ... ```) are handled specially: if a split falls inside a -/// code block, the current chunk is closed with ``` and the next chunk is reopened with -/// the original opener (preserving language tag), so each chunk renders correctly. -/// -/// Hard-splitting an over-long line breaks on **grapheme cluster** boundaries (never -/// mid-emoji / ZWJ sequence / combining mark / CJK codepoint); outside code fences it -/// also prefers whitespace boundaries so words stay intact. -/// -/// Invariant: every returned chunk satisfies `chunk.chars().count() <= limit`. A single -/// grapheme cluster wider than `limit` is split by codepoint as a last resort so the -/// limit still holds (such a cluster cannot be kept intact and also fit). +/// Compatibility wrapper for callers whose platform limit is measured in +/// Unicode scalar values. A zero legacy limit is clamped to one so malformed +/// configuration cannot create an infinite loop. pub fn split_message(text: &str, limit: usize) -> Vec { - if text.chars().count() <= limit { - return vec![text.to_string()]; + match split_message_with_budget(text, TextBudget::Characters(limit.max(1))) { + Ok(chunks) => chunks, + // A positive character budget can fit every Unicode scalar. Retain a + // content-preserving fallback if that internal invariant ever regresses. + Err(_) => text.chars().map(|value| value.to_string()).collect(), } +} +/// Split final content according to an exact platform budget. Fenced code blocks +/// are closed and reopened around splits, with those synthetic markers charged +/// to the same budget as the content. +pub(crate) fn split_message_with_budget( + text: &str, + budget: TextBudget, +) -> Result, SplitMessageError> { + let Some(limit) = budget.max() else { + return Ok(vec![text.to_string()]); + }; + if text.is_empty() { + return Ok(vec![String::new()]); + } + if limit == 0 { + let required = text + .chars() + .next() + .map_or(1, |value| budget.scalar_cost(value)); + return Err(SplitMessageError::new(budget, limit, required)); + } + if budget.measure(text) <= limit { + return Ok(vec![text.to_string()]); + } + + let newline_cost = budget.measure("\n"); + let close_marker = "\n```"; + let close_cost = budget.measure(close_marker); let mut chunks = Vec::new(); let mut current = String::new(); - let mut current_len: usize = 0; - // When inside a fenced code block, holds the full opener line (e.g. "```rust"). + let mut current_len = 0usize; let mut fence_opener: Option = None; - // Cost of appending "\n```" to close a fence before emitting a chunk. - const CLOSE_COST: usize = 4; // '\n' + '`' + '`' + '`' - for line in text.split('\n') { - let line_chars = line.chars().count(); + let line_len = budget.measure(line); let is_fence_line = line.starts_with("```"); - - // Determine overhead that must be reserved when inside a fence. - let close_reserve = if fence_opener.is_some() && !is_fence_line { - CLOSE_COST + let opens_fence = is_fence_line && fence_opener.is_none(); + let close_reserve = if opens_fence || (fence_opener.is_some() && !is_fence_line) { + close_cost } else { 0 }; - // Check whether appending this line (+ newline separator + close reserve) overflows. - if !current.is_empty() && current_len + 1 + line_chars + close_reserve > limit { - // Emit current chunk, closing fence if needed. + if !current.is_empty() + && current_len + .saturating_add(newline_cost) + .saturating_add(line_len) + .saturating_add(close_reserve) + > limit + { if let Some(ref opener) = fence_opener { - if !is_fence_line { - current.push_str("\n```"); - } + // Close the active block before every split, including when an + // unusually long original closing-fence line caused the split. + current.push_str(close_marker); chunks.push(std::mem::take(&mut current)); - // Reopen fence in next chunk with full opener (preserves language tag). current.push_str(opener); - current_len = opener.chars().count(); + current_len = budget.measure(opener); if is_fence_line { - // The closing fence marker itself triggers the split. fence_opener = None; current.push('\n'); - current_len += 1; + current_len = current_len.saturating_add(newline_cost); current.push_str(line); - current_len += line_chars; + current_len = current_len.saturating_add(line_len); continue; - } else if current_len + 1 + line_chars + CLOSE_COST <= limit { - // Line fits in the reopened chunk (with room for \n + line + close marker). + } else if current_len + .saturating_add(newline_cost) + .saturating_add(line_len) + .saturating_add(close_cost) + <= limit + { current.push('\n'); - current_len += 1; + current_len += newline_cost; current.push_str(line); - current_len += line_chars; + current_len += line_len; continue; } - // Otherwise: line doesn't fit even in a fresh reopened chunk. - // Fall through to the normal line-processing logic below, - // which will hit the hard-split path if line_chars > limit, - // or the normal append path otherwise. } else { chunks.push(std::mem::take(&mut current)); current_len = 0; } } - // Newline separator between lines within a chunk. if !current.is_empty() { current.push('\n'); - current_len += 1; + current_len = current_len.saturating_add(newline_cost); } - // Track fence state. if is_fence_line { if fence_opener.is_some() { fence_opener = None; } else { + let required = line_len.saturating_add(close_cost); + if required > limit { + return Err(SplitMessageError::new(budget, limit, required)); + } fence_opener = Some(line.to_string()); } } - // Hard-split: single line exceeds available space. - // This triggers when the line itself is longer than limit, OR when the - // line doesn't fit in the current chunk even after accounting for fence - // close overhead (e.g. after a reopen where opener already consumed space). let effective_avail = if fence_opener.is_some() { - limit.saturating_sub(current_len + CLOSE_COST) + limit.saturating_sub(current_len.saturating_add(close_cost)) } else { limit.saturating_sub(current_len) }; - if line_chars > effective_avail { - let overhead = if let Some(ref opener) = fence_opener { - // opener + '\n' at start, '\n```' at end - opener.chars().count() + 1 + CLOSE_COST - } else { - 0 - }; - // If limit can't even fit overhead, fall back to unfenced hard-split. + if line_len > effective_avail { + let overhead = fence_opener.as_ref().map_or(0, |opener| { + budget + .measure(opener) + .saturating_add(newline_cost) + .saturating_add(close_cost) + }); let capacity = limit.saturating_sub(overhead); - if let Some(opener) = fence_opener.as_ref().filter(|_| capacity > 0) { - // Fenced hard-split: each mid chunk = opener\n + chars + \n```. - // Grapheme-safe (never split an emoji / ZWJ / combining mark); no - // word-wrap — code must not be reflowed at spaces. - let opener_len = opener.chars().count(); - let mut rest = line; + if let Some(opener) = fence_opener.as_ref() { + if capacity == 0 { + let scalar_cost = line + .chars() + .next() + .map_or(1, |value| budget.scalar_cost(value)); + return Err(SplitMessageError::new( + budget, + limit, + overhead.saturating_add(scalar_cost), + )); + } - // Fill remaining space in current chunk first. + let opener_len = budget.measure(opener); + let mut rest = line; let avail_first = if current_len > 0 { - limit.saturating_sub(current_len + CLOSE_COST) + limit.saturating_sub(current_len.saturating_add(close_cost)) } else { capacity }; - let cut = split_point(rest, avail_first, false); + let cut = split_point(rest, avail_first, false, budget); current.push_str(&rest[..cut]); - current_len += rest[..cut].chars().count(); + current_len = current_len.saturating_add(budget.measure(&rest[..cut])); rest = &rest[cut..]; while !rest.is_empty() { - // Close current fenced chunk. - current.push_str("\n```"); + current.push_str(close_marker); chunks.push(std::mem::take(&mut current)); - // Reopen. current.push_str(opener); current.push('\n'); - current_len = opener_len + 1; - let mut cut = split_point(rest, capacity, false); + current_len = opener_len.saturating_add(newline_cost); + let mut cut = split_point(rest, capacity, false, budget); if cut == 0 { - // grapheme wider than capacity → codepoint-split to stay <= limit - cut = codepoint_split_point(rest, capacity); + cut = match scalar_split_point(rest, capacity, budget) { + Ok(cut) => cut, + Err(error) => { + return Err(SplitMessageError::new( + budget, + limit, + overhead.saturating_add(error.required), + )); + } + }; } current.push_str(&rest[..cut]); - current_len += rest[..cut].chars().count(); + current_len = current_len.saturating_add(budget.measure(&rest[..cut])); rest = &rest[cut..]; } } else { - // Plain hard-split (no fence or limit too small for fence wrapping). - // Grapheme-safe + prefer whitespace boundaries so words / CJK / emoji - // stay intact. let mut rest = line; while !rest.is_empty() { let avail = limit.saturating_sub(current_len); - let mut cut = split_point(rest, avail, true); + let mut cut = split_point(rest, avail, true, budget); if cut == 0 { if current.is_empty() { - // grapheme wider than limit → codepoint-split to stay <= limit - cut = codepoint_split_point(rest, avail); + cut = scalar_split_point(rest, avail, budget)?; } else { - // Nothing more fits in this chunk — flush and retry fresh. chunks.push(std::mem::take(&mut current)); current_len = 0; continue; } } current.push_str(&rest[..cut]); - current_len += rest[..cut].chars().count(); + current_len = current_len.saturating_add(budget.measure(&rest[..cut])); rest = &rest[cut..]; if !rest.is_empty() { chunks.push(std::mem::take(&mut current)); @@ -213,18 +326,25 @@ pub fn split_message(text: &str, limit: usize) -> Vec { } } else { current.push_str(line); - current_len += line_chars; + current_len = current_len.saturating_add(line_len); } } if !current.is_empty() { - // Close any trailing open fence. if fence_opener.is_some() { - current.push_str("\n```"); + current.push_str(close_marker); } chunks.push(current); } - chunks + + if let Some(oversized) = chunks + .iter() + .map(|chunk| budget.measure(chunk)) + .find(|measured| *measured > limit) + { + return Err(SplitMessageError::new(budget, limit, oversized)); + } + Ok(chunks) } /// Shorten a prompt into a thread title: collapse GitHub URLs and cap at 40 chars. @@ -269,6 +389,30 @@ mod tests { } } + fn assert_budget_invariant(chunks: &[String], budget: TextBudget, limit: usize) { + for (index, chunk) in chunks.iter().enumerate() { + let measured = budget.measure(chunk); + assert!( + measured <= limit, + "chunk {index} measures {measured}, exceeds {limit}: {chunk:?}" + ); + } + } + + fn split_for_test(text: &str, budget: TextBudget) -> Vec { + match split_message_with_budget(text, budget) { + Ok(chunks) => chunks, + Err(error) => panic!("expected split success: {error}"), + } + } + + fn split_error_for_test(text: &str, budget: TextBudget) -> SplitMessageError { + match split_message_with_budget(text, budget) { + Ok(chunks) => panic!("expected split failure, got {} chunks", chunks.len()), + Err(error) => error, + } + } + #[test] fn no_split_under_limit() { let text = "hello\nworld"; @@ -360,6 +504,55 @@ mod tests { assert_length_invariant(&chunks, 50); } + #[test] + fn closing_fence_with_suffix_keeps_every_split_chunk_balanced() { + let text = "```\naaaaaa\n``` x"; + let budget = TextBudget::Characters(15); + let chunks = split_for_test(text, budget); + assert_eq!(chunks.len(), 2); + assert_budget_invariant(&chunks, budget, 15); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.matches('a').count()) + .sum::(), + 6 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.lines().any(|line| line == "``` x")) + .count(), + 1 + ); + for chunk in chunks { + let fences = chunk.lines().filter(|line| line.starts_with("```")).count(); + assert!(fences.is_multiple_of(2), "unbalanced chunk: {chunk:?}"); + } + } + + #[test] + fn fence_overhead_that_cannot_fit_fails_closed() { + let no_content_capacity = split_error_for_test("```\nx\n```", TextBudget::Characters(8)); + assert_eq!(no_content_capacity.max, 8); + assert_eq!(no_content_capacity.required, 9); + + let oversized_opener = split_error_for_test("```rust\nx\n```", TextBudget::Characters(10)); + assert_eq!(oversized_opener.max, 10); + assert_eq!(oversized_opener.required, 11); + } + + #[test] + fn prose_splits_before_an_opener_that_needs_close_reserve() { + let text = "aaaaa\n```\nx\n```"; + let budget = TextBudget::Characters(10); + let chunks = split_for_test(text, budget); + assert_eq!(chunks.len(), 2); + assert_budget_invariant(&chunks, budget, 10); + assert_eq!(chunks[0], "aaaaa"); + assert_eq!(chunks[1], "```\nx\n```"); + } + #[test] fn multi_fence_blocks() { let text = "text\n```python\ncode1\ncode2\n```\nmore text\n```js\ncode3\n```"; @@ -468,4 +661,116 @@ mod tests { assert_length_invariant(&chunks, effective); assert_eq!(chunks.concat(), text, "content lost with mention reserve"); } + + #[test] + fn utf16_budget_counts_bmp_and_supplementary_scalars_exactly() { + let text = "A🙂B🙂C🙂D"; + let budget = TextBudget::Utf16Bytes(10); + let chunks = split_for_test(text, budget); + assert_budget_invariant(&chunks, budget, 10); + assert_eq!(chunks.concat(), text); + assert_eq!(budget.measure("A"), 2); + assert_eq!(budget.measure("🙂"), 4); + assert!(chunks.len() > 1); + } + + #[test] + fn utf8_byte_budget_differs_from_utf16_budget() { + let text = "éé🙂abc"; + let byte_budget = TextBudget::Bytes(6); + let utf16_budget = TextBudget::Utf16Bytes(6); + let byte_chunks = split_for_test(text, byte_budget); + let utf16_chunks = split_for_test(text, utf16_budget); + assert_budget_invariant(&byte_chunks, byte_budget, 6); + assert_budget_invariant(&utf16_chunks, utf16_budget, 6); + assert_eq!(byte_chunks.concat(), text); + assert_eq!(utf16_chunks.concat(), text); + assert_ne!(byte_chunks, utf16_chunks); + } + + #[test] + fn mixed_unicode_exact_budgets_preserve_content_and_bounds() { + let text = "A你e\u{301}🙂👨‍👩‍👧‍👦 Z".repeat(5); + let budgets = [ + TextBudget::Characters(4), + TextBudget::Bytes(4), + TextBudget::Utf16Bytes(4), + ]; + for budget in budgets { + let chunks = split_for_test(&text, budget); + let limit = budget.max().unwrap_or_default(); + assert_budget_invariant(&chunks, budget, limit); + assert_eq!(chunks.concat(), text); + } + } + + #[test] + fn teams_decimal_utf16_budget_is_exact_at_supplementary_boundary() { + let text = format!("{}🙂", "a".repeat(39_999)); + let budget = TextBudget::Utf16Bytes(80_000); + let chunks = split_for_test(&text, budget); + assert_eq!(chunks.len(), 2); + assert_budget_invariant(&chunks, budget, 80_000); + assert_eq!(chunks.concat(), text); + assert_eq!(budget.measure(&chunks[0]), 79_998); + assert_eq!(budget.measure(&chunks[1]), 4); + } + + #[test] + fn utf16_fenced_chunks_charge_synthetic_markers() { + let content = "🙂".repeat(20); + let text = format!("```rust\n{content}\n```"); + let budget = TextBudget::Utf16Bytes(48); + let chunks = split_for_test(&text, budget); + assert_budget_invariant(&chunks, budget, 48); + assert!(chunks.len() > 1); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.matches('🙂').count()) + .sum::(), + 20 + ); + for chunk in chunks { + let fences = chunk.lines().filter(|line| line.starts_with("```")).count(); + assert!(fences.is_multiple_of(2), "unbalanced chunk: {chunk:?}"); + } + } + + #[test] + fn budget_split_keeps_graphemes_when_they_fit() { + let family = "👨‍👩‍👧‍👦"; + let text = format!("{family} {family} {family}"); + let one_family = TextBudget::Utf16Bytes(usize::MAX).measure(family); + let budget = TextBudget::Utf16Bytes(one_family + 2); + let chunks = split_for_test(&text, budget); + assert_budget_invariant(&chunks, budget, one_family + 2); + let flattened: Vec<&str> = chunks + .iter() + .flat_map(|chunk| chunk.graphemes(true)) + .collect(); + let original: Vec<&str> = text.graphemes(true).collect(); + assert_eq!(flattened, original); + } + + #[test] + fn unlimited_budget_returns_one_unchanged_chunk() { + let text = "```rust\nfn main() {}\n```\n🙂".repeat(100); + assert_eq!(split_for_test(&text, TextBudget::Unlimited), vec![text]); + } + + #[test] + fn impossible_budget_fails_without_invalid_utf8_or_oversize() { + let utf16 = split_error_for_test("🙂", TextBudget::Utf16Bytes(2)); + assert_eq!(utf16.max, 2); + assert_eq!(utf16.required, 4); + + let utf8 = split_error_for_test("é", TextBudget::Bytes(1)); + assert_eq!(utf8.max, 1); + assert_eq!(utf8.required, 2); + + let zero = split_error_for_test("a", TextBudget::Characters(0)); + assert_eq!(zero.max, 0); + assert_eq!(zero.required, 1); + } } diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index a3b74adbd..d9dcd3166 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1,68 +1,153 @@ use crate::acp::ContentBlock; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; +use crate::adapter::{ + AdapterCapabilities, ChannelRef, ChatAdapter, MaterializedAttachment, MessageLimit, MessageRef, + PersistentConversationTarget, SenderContext, StatusBackend, StreamingMode, WriteFailure, + WriteOutcome, WriteOutcomeKind, +}; +use crate::commands::{parse_command, render_text_result, Command, CommandContext, CommandService}; use anyhow::Result; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}; +use std::sync::{Arc, RwLock}; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tracing::{error, info, warn}; -/// Timeout for waiting on gateway reply acknowledgement. -const GATEWAY_REPLY_TIMEOUT_SECS: u64 = 5; +const LEGACY_GATEWAY_REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const ATTACHMENT_MATERIALIZATION_RESPONSE_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(45); +const GATEWAY_WS_MESSAGE_LIMIT: usize = 8 * 1024 * 1024; -/// Platforms whose gateway adapter emits a `GatewayResponse` for `edit_message` -/// so core can observe edit success or failure (used to gate the per-edit -/// response-wait below). -/// -/// Today only Feishu does, because it is the only adapter with a known -/// per-message edit cap (errcode 230072) that requires core-side recovery, and -/// the only one wired to ack edits. -/// -/// NOTE: this gates the `edit_message` response-wait only. `delete_message` is -/// unconditionally fire-and-forget (the recovery path sends fresh content -/// regardless of the delete outcome), so it does not consult this list. -/// -/// TECH DEBT: this is platform-identity standing in for a *capability*. The -/// right model is a capability handshake at gateway-connect time ("does this -/// adapter acknowledge edits?") rather than a hardcoded platform name. We -/// accept the hardcode now because there is no handshake protocol yet; when one -/// lands, replace this allowlist with a negotiated capability flag. Any new -/// adapter that wires request/response for edits MUST be added here, or its -/// edit failures stay invisible to core (silent failure mode). -const EDIT_RESPONSE_PLATFORMS: &[&str] = &["feishu"]; - -/// Whether `platform` acknowledges `edit_message` with a `GatewayResponse`. -/// See `EDIT_RESPONSE_PLATFORMS`. -fn platform_acks_writes(platform: &str) -> bool { - EDIT_RESPONSE_PLATFORMS.contains(&platform) +fn write_failure(outcome: WriteOutcome) -> anyhow::Error { + WriteFailure::new(outcome).into() } -/// Gateway platforms whose messaging API cannot edit a message after it is sent. -/// -/// Cosmetic (typewriter) streaming works by posting a placeholder and then -/// repeatedly editing it in place with the growing text. On a platform with no -/// edit endpoint, each of those "edits" is delivered as a brand-new message -/// instead — so the user sees the same reply posted several times, each copy -/// longer than the last. Streaming is therefore force-disabled (send-once) for -/// these platforms regardless of the configured `streaming` flag. -/// -/// LINE's Messaging API only exposes reply/push (no edit), so it lives here. -/// (The in-process unified adapter additionally hard-drops stray edit_message -/// commands in the LINE adapter itself — see `dispatch_line_reply`.) -/// -/// NOTE: like `EDIT_RESPONSE_PLATFORMS`, this is platform-identity standing in -/// for a *capability*. The right long-term model is a capability handshake at -/// gateway-connect time ("can this adapter edit messages?"); until that exists, -/// any new gateway platform that lacks a message-edit API MUST be added here. -const NON_EDITABLE_PLATFORMS: &[&str] = &["line", "lineworks"]; - -/// Whether cosmetic streaming (placeholder + in-place edits) is possible on -/// `platform`. See `NON_EDITABLE_PLATFORMS`. -fn platform_supports_streaming(platform: &str) -> bool { - !NON_EDITABLE_PLATFORMS.contains(&platform) +fn unknown_write_failure(code: &str, message: impl Into) -> anyhow::Error { + write_failure(WriteOutcome::Unknown { + code: code.to_owned(), + message: message.into(), + }) +} + +fn command_target_fields( + msg: &MessageRef, + negotiated: bool, + capabilities: &AdapterCapabilities, +) -> (String, Option) { + if negotiated && capabilities.supports_target_message_id { + ( + msg.channel.origin_event_id.clone().unwrap_or_default(), + Some(msg.message_id.clone()), + ) + } else { + // Old Gateways know only the overloaded command form where `reply_to` + // carries the platform message target. + (msg.message_id.clone(), None) + } +} + +/// Capability fallback used only when the peer does not negotiate a hello. +/// It preserves the pre-handshake behavior while keeping platform identity out +/// of the write and streaming control paths themselves. +fn legacy_gateway_capabilities( + platform: &str, + streaming: bool, + streaming_placeholder: bool, +) -> AdapterCapabilities { + // Preserve the pre-handshake platform behavior exactly. ACP was already + // forced send-once by the router; LINE and LINE WORKS were the only legacy + // gateway platforms on the non-editable allowlist. + let can_edit = !matches!(platform, "line" | "lineworks" | "acp"); + AdapterCapabilities { + send_ack: false, + edit_ack: platform == "feishu", + delete_ack: false, + supports_target_message_id: false, + supports_reactions: true, + supports_attachment_materialization: false, + supports_conversation_registry: false, + supports_persistent_conversation_send: false, + can_edit, + can_delete: platform == "feishu", + streaming_mode: if streaming && can_edit { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }, + show_streaming_placeholder: streaming_placeholder, + message_limit: if platform == "acp" { + MessageLimit::Unlimited + } else { + MessageLimit::Characters { max: 4096 } + }, + status_backend: StatusBackend::Reactions, + } +} + +fn teams_message_status_supported( + negotiated: bool, + capabilities: &AdapterCapabilities, +) -> bool { + negotiated + && capabilities.send_ack + && capabilities.edit_ack + && capabilities.delete_ack + && capabilities.supports_target_message_id + && capabilities.can_edit + && capabilities.can_delete +} + +fn normalize_reaction_support(capabilities: &mut AdapterCapabilities) { + capabilities.supports_reactions |= + capabilities.status_backend == StatusBackend::Reactions; +} + +fn teams_progressive_response_supported( + negotiated: bool, + capabilities: &AdapterCapabilities, +) -> bool { + negotiated + && capabilities.send_ack + && capabilities.edit_ack + && capabilities.delete_ack + && capabilities.supports_target_message_id + && capabilities.can_edit + && capabilities.can_delete + && capabilities.show_streaming_placeholder +} + +/// Apply the same fail-closed Teams progressive-response predicate in +/// Standalone and Unified deployment modes. +pub fn apply_teams_progressive_capabilities( + available: bool, + enabled: bool, + capabilities: &mut AdapterCapabilities, +) { + capabilities.streaming_mode = + if enabled && teams_progressive_response_supported(available, capabilities) { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }; +} + +fn apply_teams_processing_indicator( + negotiated: bool, + enabled: bool, + capabilities: &mut AdapterCapabilities, +) { + if !enabled { + return; + } + capabilities.status_backend = + if teams_message_status_supported(negotiated, capabilities) { + StatusBackend::Message + } else { + StatusBackend::None + }; } /// Shared filter parameters for gateway event gating. @@ -94,8 +179,32 @@ fn should_skip_event(event: &GatewayEvent, filter: &EventFilterParams) -> bool { tracing::info!(sender = %event.sender.id, "gateway: user not in allowed_users, skipping"); return true; } - // @mention gating: in groups, only respond if bot is mentioned - let is_group = event.channel.channel_type == "group" || event.channel.channel_type == "supergroup"; + // Teams trusts structured mention entity IDs, never display text. Personal + // chat needs no mention; groupChat/channel always require a recipient + // mention and do not gain an ambient/thread bypass. + if event.platform.eq_ignore_ascii_case("teams") { + if let Some(scope) = event.scope.as_ref() { + return match scope.conversation_type.as_str() { + "personal" => !scope.is_dm, + "groupChat" | "channel" if !scope.is_dm => event + .recipient + .as_ref() + .map(|recipient| recipient.id.as_str()) + .filter(|id| !id.trim().is_empty()) + .is_none_or(|recipient_id| { + !event + .mentions + .iter() + .any(|mention_id| mention_id == recipient_id) + }), + _ => true, + }; + } + } + + // Legacy/non-Teams @mention gating retains the existing group behavior. + let is_group = + event.channel.channel_type == "group" || event.channel.channel_type == "supergroup"; let in_thread = event.channel.thread_id.is_some(); if is_group && !in_thread { if let Some(bot_name) = filter.bot_username { @@ -121,9 +230,42 @@ struct GatewayEvent { sender: GwSender, content: GwContent, #[serde(default)] - #[allow(dead_code)] mentions: Vec, message_id: String, + #[serde(default)] + scope: Option, + #[serde(default)] + recipient: Option, + #[serde(default)] + mention_entities: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] +struct GwScope { + #[serde(default)] + tenant_id: Option, + #[serde(default)] + team_id: Option, + #[serde(default)] + channel_id: Option, + conversation_type: String, + trust_scope_id: String, + is_dm: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] +struct GwRecipient { + id: String, + #[serde(default)] + #[allow(dead_code)] + name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize)] +struct GwMention { + id: String, + #[serde(default)] + text: String, } #[derive(Clone, Debug, Deserialize)] @@ -159,6 +301,8 @@ struct GwAttachment { filename: String, mime_type: String, #[serde(default)] + reference: Option, + #[serde(default)] data: String, #[allow(dead_code)] size: u64, @@ -170,6 +314,257 @@ struct GwAttachment { status: Option, } +/// Teams-specific L2 policy for authenticated typed Gateway scope. Identity +/// remains in the shared trust registry and is evaluated only after this gate. +#[derive(Clone, Debug)] +pub struct TeamsScopePolicy { + typed_configured: bool, + allowed_teams: HashSet, + allowed_channels: HashSet, + allow_personal: bool, + allow_group_chats: bool, + legacy_allow_all_channels: bool, + legacy_allowed_conversations: HashSet, +} + +fn typed_scope_shape_is_valid(conversation_id: &str, channel_type: &str, scope: &GwScope) -> bool { + let present = |value: Option<&str>| value.is_some_and(|value| !value.trim().is_empty()); + if conversation_id.trim().is_empty() + || !present(scope.tenant_id.as_deref()) + || scope.trust_scope_id.trim().is_empty() + || scope.conversation_type != channel_type + { + return false; + } + + match scope.conversation_type.as_str() { + "personal" => scope.is_dm, + "groupChat" => !scope.is_dm, + "channel" => { + !scope.is_dm + && present(scope.team_id.as_deref()) + && present(scope.channel_id.as_deref()) + } + _ => false, + } +} + +impl TeamsScopePolicy { + pub fn new( + typed_configured: bool, + allowed_teams: impl IntoIterator, + allowed_channels: impl IntoIterator, + allow_personal: bool, + allow_group_chats: bool, + legacy_allow_all_channels: bool, + legacy_allowed_conversations: impl IntoIterator, + ) -> Self { + Self { + typed_configured, + allowed_teams: allowed_teams.into_iter().collect(), + allowed_channels: allowed_channels.into_iter().collect(), + allow_personal, + allow_group_chats, + legacy_allow_all_channels, + legacy_allowed_conversations: legacy_allowed_conversations.into_iter().collect(), + } + } + + pub fn uses_legacy_fallback(&self) -> bool { + !self.typed_configured + } + + pub fn legacy_scope_restricted(&self) -> bool { + !self.legacy_allow_all_channels + } + + fn surface_allowed(&self, conversation_id: &str, channel_type: &str, scope: &GwScope) -> bool { + if !typed_scope_shape_is_valid(conversation_id, channel_type, scope) { + return false; + } + + if !self.typed_configured { + return self.legacy_allow_all_channels + || self.legacy_allowed_conversations.contains(conversation_id); + } + + match scope.conversation_type.as_str() { + "personal" => self.allow_personal, + "groupChat" => self.allow_group_chats, + "channel" => { + (self.allowed_teams.is_empty() && self.allowed_channels.is_empty()) + || scope + .team_id + .as_ref() + .is_some_and(|team| self.allowed_teams.contains(team)) + || scope + .channel_id + .as_ref() + .is_some_and(|channel| self.allowed_channels.contains(channel)) + } + _ => false, + } + } +} + +impl Default for TeamsScopePolicy { + fn default() -> Self { + Self::new( + false, + Vec::::new(), + Vec::::new(), + true, + true, + true, + Vec::::new(), + ) + } +} + +fn strip_recipient_mention(event: &GatewayEvent) -> String { + if !event.platform.eq_ignore_ascii_case("teams") { + return event.content.text.clone(); + } + let Some(recipient_id) = event + .recipient + .as_ref() + .map(|recipient| recipient.id.as_str()) + .filter(|id| !id.trim().is_empty()) + else { + return event.content.text.clone(); + }; + + let mut ranges = Vec::new(); + let mut cursor = 0; + for mention in &event.mention_entities { + if mention.text.is_empty() || cursor > event.content.text.len() { + continue; + } + let Some(relative_start) = event.content.text[cursor..].find(&mention.text) else { + continue; + }; + let start = cursor + relative_start; + let end = start + mention.text.len(); + cursor = end; + if mention.id == recipient_id { + ranges.push(start..end); + } + } + + let mut prompt = event.content.text.clone(); + for range in ranges.into_iter().rev() { + prompt.replace_range(range, ""); + } + prompt.trim().to_owned() +} + +fn gateway_command_context(event: &GatewayEvent) -> CommandContext { + let logical_thread_id = event + .channel + .thread_id + .as_deref() + .unwrap_or(&event.channel.id); + let response_is_private = event.platform.eq_ignore_ascii_case("teams") + && event.scope.as_ref().is_some_and(|scope| { + scope.conversation_type == "personal" + && scope.is_dm + && typed_scope_shape_is_valid(&event.channel.id, &event.channel.channel_type, scope) + }); + CommandContext::new( + event.platform.clone(), + logical_thread_id.to_string(), + response_is_private, + ) +} + +fn spawn_gateway_command( + tasks: &mut tokio::task::JoinSet<()>, + command: Command, + context: CommandContext, + service: CommandService, + adapter: Arc, + channel: ChannelRef, +) { + tasks.spawn(execute_gateway_command( + command, context, service, adapter, channel, + )); +} + +fn trusted_conversation_registration_allowed(event: &GatewayEvent) -> bool { + event.platform.eq_ignore_ascii_case("teams") + && event.scope.as_ref().is_some_and(|scope| { + typed_scope_shape_is_valid(&event.channel.id, &event.channel.channel_type, scope) + }) +} + +async fn register_trusted_gateway_conversation( + adapter: Arc, + channel: ChannelRef, + typed_scope_allowed: bool, +) { + if !typed_scope_allowed + || !channel.platform.eq_ignore_ascii_case("teams") + || !adapter + .capabilities(&channel.platform) + .supports_conversation_registry + { + return; + } + if adapter.register_conversation(&channel).await.is_err() { + warn!( + platform = "teams", + outcome = "not_completed", + "trusted conversation registration did not complete" + ); + } +} + +fn spawn_teams_gateway_event( + tasks: &mut tokio::task::JoinSet<()>, + event_json: String, + event_context: Arc, + event_order: Arc>, + serialize: bool, +) { + tasks.spawn(async move { + let result = if serialize { + let _guard = event_order.lock().await; + process_gateway_event(&event_json, &event_context).await + } else { + process_gateway_event(&event_json, &event_context).await + }; + if let Err(error) = result { + warn!(error = %error, "teams event processing failed"); + } + }); +} + +async fn execute_gateway_command( + command: Command, + context: CommandContext, + service: CommandService, + adapter: Arc, + channel: ChannelRef, +) { + let command_name = command.name(); + let result = service.execute(command, &context).await; + let semantic_outcome = result.outcome_class(); + let content = render_text_result(&result); + let write_outcome = adapter.send_message_outcome(&channel, &content).await; + let write_outcome = match write_outcome { + WriteOutcome::Delivered { .. } => "delivered", + WriteOutcome::Rejected { .. } => "rejected", + WriteOutcome::Unknown { .. } => "unknown", + }; + tracing::info!( + platform = %context.platform, + command = command_name.as_str(), + semantic_outcome, + write_outcome, + "gateway command completed" + ); +} + #[derive(Serialize)] struct GatewayReply { schema: String, @@ -186,6 +581,15 @@ struct GatewayReply { /// the visual reply/quote UI on the platform. Falls back to plain send on failure. #[serde(skip_serializing_if = "Option::is_none")] quote_message_id: Option, + /// Platform message targeted by an edit/delete/reaction command. New peers + /// keep `reply_to` as origin event correlation; legacy peers receive the + /// command target in `reply_to` instead. + #[serde(skip_serializing_if = "Option::is_none")] + target_message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + attachment_ref: Option, + #[serde(skip_serializing_if = "Option::is_none")] + persistent_conversation: Option>, } #[derive(Serialize)] @@ -211,11 +615,158 @@ struct GatewayResponse { thread_id: Option, message_id: Option, error: Option, + #[serde(default)] + outcome: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + retry_after_ms: Option, + #[serde(default)] + attachment: Option, +} + +impl GatewayResponse { + fn write_outcome(&self) -> WriteOutcome { + match self.outcome { + Some(WriteOutcomeKind::Delivered) => WriteOutcome::Delivered { + message_id: self.message_id.clone(), + }, + Some(WriteOutcomeKind::Rejected) => WriteOutcome::Rejected { + code: self.error_code.clone().unwrap_or_else(|| "rejected".into()), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway rejected write".into()), + retry_after_ms: self.retry_after_ms, + }, + Some(WriteOutcomeKind::Unknown) => WriteOutcome::Unknown { + code: self.error_code.clone().unwrap_or_else(|| "unknown".into()), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway write outcome is unknown".into()), + }, + None if self.success => WriteOutcome::Delivered { + message_id: self.message_id.clone(), + }, + None => WriteOutcome::Rejected { + code: "legacy_failure".into(), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway reported failure".into()), + retry_after_ms: None, + }, + } + } +} + +const CLIENT_HELLO_SCHEMA: &str = "openab.gateway.client_hello.v1"; +const GATEWAY_HELLO_SCHEMA: &str = "openab.gateway.hello.v1"; +const GATEWAY_PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Deserialize)] +struct GatewayEnvelope { + schema: String, +} + +#[derive(Debug, Serialize)] +struct GatewayClientHello { + schema: String, + protocol_version: u32, + client_name: Option, + requested_platforms: Vec, +} + +fn build_client_hello() -> GatewayClientHello { + GatewayClientHello { + schema: CLIENT_HELLO_SCHEMA.into(), + protocol_version: GATEWAY_PROTOCOL_VERSION, + client_name: Some(format!("openab-core/{}", env!("CARGO_PKG_VERSION"))), + // A standalone Gateway can publish several platforms over one socket, + // so Core requests the full configured capability map. + requested_platforms: Vec::new(), + } +} + +#[derive(Clone, Debug, Deserialize)] +struct GatewayHello { + schema: String, + protocol_version: u32, + #[serde(default)] + capabilities: HashMap, + topology: GatewayTopology, +} + +#[derive(Clone, Debug, Deserialize)] +struct GatewayTopology { + active_consumers: usize, + supported: bool, + delivery_mode: String, +} + +#[derive(Default)] +struct GatewayCapabilityState { + hello: std::sync::RwLock>, +} + +impl GatewayCapabilityState { + fn update(&self, hello: GatewayHello) { + *self + .hello + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(hello); + } + + fn topology_supported(&self) -> bool { + self.hello + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .is_some_and(|hello| hello.topology.supported && hello.topology.active_consumers == 1) + } + + fn resolve(&self, platform: &str, legacy: &AdapterCapabilities) -> (bool, AdapterCapabilities) { + let hello = self + .hello + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match hello.as_ref() { + Some(hello) => ( + true, + hello + .capabilities + .get(platform) + .cloned() + .unwrap_or_default(), + ), + None => (false, legacy.clone()), + } + } } // --- GatewayAdapter: ChatAdapter over WebSocket --- type PendingRequests = Arc>>>; + +/// Removes a pending attachment request when its future is cancelled by the +/// whole-batch deadline. Normal responses remove the same key in the reader, +/// so this cleanup is a no-op on the success path. +struct PendingGatewayRequest { + pending: PendingRequests, + request_id: String, +} + +impl Drop for PendingGatewayRequest { + fn drop(&mut self) { + let pending = self.pending.clone(); + let request_id = self.request_id.clone(); + tokio::spawn(async move { + pending.lock().await.remove(&request_id); + }); + } +} + type SharedWsTx = Arc< Mutex< futures_util::stream::SplitSink< @@ -227,61 +778,402 @@ type SharedWsTx = Arc< >, >; -pub struct GatewayAdapter { - ws_tx: SharedWsTx, - pending: PendingRequests, +/// Stable adapter handle shared with cron while the standalone Gateway socket +/// reconnects. It never opens a socket and clears only the generation that +/// installed the current concrete adapter. +pub struct GatewayAdapterProxy { platform_name: &'static str, - streaming: bool, - streaming_placeholder: bool, - telegram_rich_messages: bool, + next_generation: AtomicU64, + current: RwLock)>>, } -impl GatewayAdapter { - fn new( - ws_tx: SharedWsTx, - pending: PendingRequests, - platform_name: &'static str, - streaming: bool, - streaming_placeholder: bool, - telegram_rich_messages: bool, - ) -> Self { - Self { - ws_tx, - pending, +impl GatewayAdapterProxy { + pub fn new(platform: String) -> Arc { + Self::with_platform_name(Box::leak(platform.into_boxed_str())) + } + + fn with_platform_name(platform_name: &'static str) -> Arc { + Arc::new(Self { platform_name, - streaming, - streaming_placeholder, - telegram_rich_messages, + next_generation: AtomicU64::new(1), + current: RwLock::new(None), + }) + } + + fn install(&self, adapter: Arc) -> u64 { + let generation = self.next_generation.fetch_add(1, AtomicOrdering::AcqRel); + *self + .current + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((generation, adapter)); + generation + } + + fn clear_generation(&self, generation: u64) { + let mut current = self + .current + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if current + .as_ref() + .is_some_and(|(installed, _)| *installed == generation) + { + *current = None; } } - /// Internal helper for send_message / send_message_with_reply. - async fn send_gateway_reply( + fn current(&self) -> Result> { + self.current + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map(|(_, adapter)| adapter.clone()) + .ok_or_else(|| { + write_failure(WriteOutcome::Rejected { + code: "gateway_disconnected".into(), + message: "Gateway connection is unavailable".into(), + retry_after_ms: None, + }) + }) + } +} + +#[async_trait] +impl ChatAdapter for GatewayAdapterProxy { + fn platform(&self) -> &'static str { + self.platform_name + } + + fn message_limit(&self) -> usize { + self.current() + .map_or(4096, |adapter| adapter.message_limit()) + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + self.current().map_or_else( + |_| AdapterCapabilities::default(), + |adapter| adapter.capabilities(platform), + ) + } + + async fn materialize_attachment( + &self, + channel: &ChannelRef, + reference: &str, + ) -> Result { + self.current()? + .materialize_attachment(channel, reference) + .await + } + + async fn register_conversation(&self, channel: &ChannelRef) -> Result<()> { + self.current()?.register_conversation(channel).await + } + + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + self.current()?.send_message(channel, content).await + } + + async fn send_message_with_reply( &self, channel: &ChannelRef, content: &str, - quote_message_id: Option<&str>, + reply_to_message_id: &str, ) -> Result { - let req_id = if self.streaming { - Some(format!("req_{}", uuid::Uuid::new_v4())) - } else { - None - }; - let pending_rx = if let Some(ref id) = req_id { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.pending.lock().await.insert(id.clone(), tx); - Some(rx) - } else { - None - }; - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: channel.origin_event_id.clone().unwrap_or_default(), - platform: channel.platform.clone(), - channel: ReplyChannel { - id: channel.channel_id.clone(), - thread_id: channel.thread_id.clone(), - }, + self.current()? + .send_message_with_reply(channel, content, reply_to_message_id) + .await + } + + async fn create_thread( + &self, + channel: &ChannelRef, + trigger_msg: &MessageRef, + title: &str, + ) -> Result { + self.current()? + .create_thread(channel, trigger_msg, title) + .await + } + + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.current()?.add_reaction(msg, emoji).await + } + + async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.current()?.remove_reaction(msg, emoji).await + } + + async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { + self.current()?.edit_message(msg, content).await + } + + async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + self.current()?.delete_message(msg).await + } + + fn use_streaming(&self, other_bot_present: bool) -> bool { + self.current() + .is_ok_and(|adapter| adapter.use_streaming(other_bot_present)) + } + + fn show_streaming_placeholder(&self) -> bool { + self.current() + .is_ok_and(|adapter| adapter.show_streaming_placeholder()) + } + + fn renders_native_tables(&self, platform: &str) -> bool { + self.current() + .is_ok_and(|adapter| adapter.renders_native_tables(platform)) + } +} + +struct GatewayAdapterOptions { + platform_name: &'static str, + streaming: bool, + streaming_placeholder: bool, + telegram_rich_messages: bool, + teams_processing_indicator: bool, + teams_streaming: bool, + teams_inbound_attachments: bool, + gateway_ack_timeout_secs: u64, +} + +pub struct GatewayAdapter { + ws_tx: SharedWsTx, + pending: PendingRequests, + connection_active: Arc, + capability_state: Arc, + legacy_capabilities: AdapterCapabilities, + platform_name: &'static str, + streaming: bool, + streaming_placeholder: bool, + telegram_rich_messages: bool, + teams_processing_indicator: bool, + teams_streaming: bool, + teams_inbound_attachments: bool, + ack_timeout: std::time::Duration, +} + +impl GatewayAdapter { + fn new( + ws_tx: SharedWsTx, + pending: PendingRequests, + connection_active: Arc, + capability_state: Arc, + options: GatewayAdapterOptions, + ) -> Self { + let GatewayAdapterOptions { + platform_name, + streaming, + streaming_placeholder, + telegram_rich_messages, + teams_processing_indicator, + teams_streaming, + teams_inbound_attachments, + gateway_ack_timeout_secs, + } = options; + Self { + ws_tx, + pending, + connection_active, + capability_state, + legacy_capabilities: legacy_gateway_capabilities( + platform_name, + streaming, + streaming_placeholder, + ), + platform_name, + streaming, + streaming_placeholder, + telegram_rich_messages, + teams_processing_indicator, + teams_streaming, + teams_inbound_attachments, + ack_timeout: std::time::Duration::from_secs(gateway_ack_timeout_secs.max(1)), + } + } + + fn resolved_capabilities_with_mode(&self, platform: &str) -> (bool, AdapterCapabilities) { + let (negotiated, mut capabilities) = self + .capability_state + .resolve(platform, &self.legacy_capabilities); + let teams = platform.eq_ignore_ascii_case("teams"); + if teams { + capabilities.supports_attachment_materialization &= self.teams_inbound_attachments + && negotiated + && self.capability_state.topology_supported(); + capabilities.supports_conversation_registry &= + negotiated && self.capability_state.topology_supported(); + capabilities.supports_persistent_conversation_send &= + negotiated && self.capability_state.topology_supported() && capabilities.send_ack; + // Teams has an independent default-off opt-in. Do not inherit the + // generic Gateway streaming or placeholder switches. + apply_teams_progressive_capabilities( + negotiated, + self.teams_streaming, + &mut capabilities, + ); + } else { + if !self.streaming { + capabilities.streaming_mode = StreamingMode::Disabled; + } + capabilities.show_streaming_placeholder &= self.streaming_placeholder; + } + // Older peers represented reaction availability only through the + // selected status backend. Normalize that shape before a configured + // processing message overrides transient progress selection. + normalize_reaction_support(&mut capabilities); + if teams { + apply_teams_processing_indicator( + negotiated, + self.teams_processing_indicator, + &mut capabilities, + ); + } + (negotiated, capabilities) + } + + fn resolved_capabilities(&self, platform: &str) -> AdapterCapabilities { + self.resolved_capabilities_with_mode(platform).1 + } + + fn ensure_persistent_write_available( + &self, + channel: &ChannelRef, + negotiated: bool, + capabilities: &AdapterCapabilities, + ) -> Result<()> { + let Some(target) = channel.persistent_conversation.as_ref() else { + return Ok(()); + }; + let identity_valid = channel.platform == "teams" + && channel.channel_id == target.conversation_id + && channel.thread_id.is_none() + && channel.origin_event_id.as_deref().is_none_or(str::is_empty) + && target.bot_framework_channel_id == "msteams"; + let capability_available = self.connection_active.load(AtomicOrdering::Acquire) + && negotiated + && self.capability_state.topology_supported() + && capabilities.send_ack + && capabilities.supports_target_message_id + && capabilities.supports_persistent_conversation_send; + if identity_valid && capability_available { + return Ok(()); + } + Err(write_failure(WriteOutcome::Rejected { + code: if identity_valid { + "persistent_send_unavailable".into() + } else { + "persistent_target_invalid".into() + }, + message: "Gateway persistent conversation send is unavailable".into(), + retry_after_ms: None, + })) + } + + async fn send_reaction_command( + &self, + msg: &MessageRef, + emoji: &str, + command: &'static str, + ) -> Result<()> { + let (negotiated, capabilities) = + self.resolved_capabilities_with_mode(&msg.channel.platform); + self.ensure_persistent_write_available(&msg.channel, negotiated, &capabilities)?; + let required_ack = msg.channel.persistent_conversation.is_some(); + let request_id = required_ack.then(|| format!("req_{}", uuid::Uuid::new_v4())); + let pending_rx = if let Some(ref id) = request_id { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.pending.lock().await.insert(id.clone(), tx); + Some(rx) + } else { + None + }; + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); + let reply = GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to, + platform: msg.channel.platform.clone(), + channel: ReplyChannel { + id: msg.channel.channel_id.clone(), + thread_id: msg.channel.thread_id.clone(), + }, + content: ReplyContent { + content_type: "text".into(), + text: emoji.into(), + }, + command: Some(command.into()), + quote_message_id: None, + target_message_id, + request_id: request_id.clone(), + persistent_conversation: msg.channel.persistent_conversation.clone(), + }; + let json = serde_json::to_string(&reply)?; + if let Err(error) = self.ws_tx.lock().await.send(Message::Text(json)).await { + if let Some(ref id) = request_id { + self.pending.lock().await.remove(id); + } + return Err(unknown_write_failure( + "gateway_reaction_send_failed", + error.to_string(), + )); + } + let (Some(rx), Some(id)) = (pending_rx, request_id) else { + return Ok(()); + }; + match tokio::time::timeout(self.ack_timeout, rx).await { + Ok(Ok(response)) => match response.write_outcome() { + WriteOutcome::Delivered { .. } => Ok(()), + outcome @ (WriteOutcome::Rejected { .. } | WriteOutcome::Unknown { .. }) => { + Err(write_failure(outcome)) + } + }, + Ok(Err(_)) => Err(unknown_write_failure( + "reaction_ack_channel_closed", + "required reaction ACK channel closed", + )), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(unknown_write_failure( + "reaction_ack_timeout", + "required reaction ACK timed out", + )) + } + } + } + + /// Internal helper for send_message / send_message_with_reply. + async fn send_gateway_reply( + &self, + channel: &ChannelRef, + content: &str, + quote_message_id: Option<&str>, + ) -> Result { + let (negotiated, capabilities) = self.resolved_capabilities_with_mode(&channel.platform); + self.ensure_persistent_write_available(channel, negotiated, &capabilities)?; + let required_ack = negotiated && capabilities.send_ack; + // Preserve legacy streaming correlation without turning a missing ACK + // into failure. New peers request an ACK only when it was advertised. + let request_ack = required_ack || (!negotiated && self.streaming); + let req_id = request_ack.then(|| format!("req_{}", uuid::Uuid::new_v4())); + let pending_rx = if let Some(ref id) = req_id { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.pending.lock().await.insert(id.clone(), tx); + Some(rx) + } else { + None + }; + let reply = GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: channel.origin_event_id.clone().unwrap_or_default(), + platform: channel.platform.clone(), + channel: ReplyChannel { + id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + }, content: ReplyContent { content_type: "text".into(), text: content.into(), @@ -289,41 +1181,81 @@ impl GatewayAdapter { command: None, request_id: req_id.clone(), quote_message_id: quote_message_id.map(|s| s.to_string()), + target_message_id: None, + persistent_conversation: channel.persistent_conversation.clone(), }; let json = serde_json::to_string(&reply)?; if let Err(e) = self.ws_tx.lock().await.send(Message::Text(json)).await { if let Some(ref id) = req_id { self.pending.lock().await.remove(id); } - return Err(e.into()); + return Err(unknown_write_failure("gateway_send_failed", e.to_string())); } let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { - match tokio::time::timeout(std::time::Duration::from_secs(GATEWAY_REPLY_TIMEOUT_SECS), rx).await { - Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), - Ok(Ok(resp)) => { - // Gateway explicitly reported failure (success=false). Surface - // as Err so dispatch sets ❌ instead of 🆗 over an incomplete - // delivery. Examples: Feishu edit cap reached after append-new - // fallback also failed; chunked send delivered N/M chunks. - let err_msg = resp.error.clone() - .unwrap_or_else(|| "gateway reported failure".to_string()); - tracing::warn!(request_id = %id, error = %err_msg, "gateway replied with failure"); - return Err(anyhow::anyhow!("gateway reported failure: {err_msg}")); + let response_timeout = if required_ack { + self.ack_timeout + } else { + LEGACY_GATEWAY_REPLY_TIMEOUT + }; + match tokio::time::timeout(response_timeout, rx).await { + Ok(Ok(resp)) => match resp.write_outcome() { + WriteOutcome::Delivered { message_id } => match message_id { + Some(message_id) if !message_id.is_empty() => message_id, + _ if required_ack => { + return Err(unknown_write_failure( + "missing_message_id", + "gateway delivered send without a message id", + )); + } + _ => "gw_sent".into(), + }, + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => { + warn!( + request_id = %id, + error_code = %code, + retry_after_ms, + error = %message, + "gateway rejected write" + ); + return Err(write_failure(WriteOutcome::Rejected { + code, + message, + retry_after_ms, + })); + } + WriteOutcome::Unknown { code, message } => { + warn!( + request_id = %id, + error_code = %code, + error = %message, + "gateway write outcome unknown; not retrying" + ); + return Err(write_failure(WriteOutcome::Unknown { code, message })); + } + }, + Ok(Err(_)) if required_ack => { + return Err(unknown_write_failure( + "send_ack_channel_closed", + "required gateway ACK channel closed", + )); } Ok(Err(_)) => { - // Channel closed (gateway shutting down or pending dropped). - // Maintain legacy behavior — adapters that don't implement - // GatewayResponse for all reply types (LINE, Teams) rely on - // this for non-failure outcomes. - tracing::warn!(request_id = %id, "gateway response channel closed"); + warn!(request_id = %id, "legacy gateway response channel closed"); "gw_sent".into() } + Err(_) if required_ack => { + self.pending.lock().await.remove(id); + return Err(unknown_write_failure( + "send_ack_timeout", + "required gateway ACK timed out", + )); + } Err(_) => { - // Timeout. Many adapters (LINE, Teams) intentionally do not - // emit GatewayResponse for replies, so timeout is the expected - // path for them. Maintain legacy behavior to avoid breaking - // platforms that have not yet wired request/response feedback. - tracing::warn!(request_id = %id, "gateway reply timed out"); + warn!(request_id = %id, "legacy gateway reply timed out"); self.pending.lock().await.remove(id); "gw_sent".into() } @@ -336,159 +1268,172 @@ impl GatewayAdapter { message_id: msg_id, }) } -} - -/// Send a fire-and-forget reply via the shared WebSocket (no request-response). -/// Used for slash command responses where we don't need message_id back. -async fn send_fire_and_forget( - ws_tx: &SharedWsTx, - channel: &ChannelRef, - content: &str, -) -> Result<()> { - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: channel.origin_event_id.clone().unwrap_or_default(), - platform: channel.platform.clone(), - channel: ReplyChannel { - id: channel.channel_id.clone(), - thread_id: channel.thread_id.clone(), - }, - content: ReplyContent { - content_type: "text".into(), - text: content.into(), - }, - command: None, - request_id: None, - quote_message_id: None, - }; - let json = serde_json::to_string(&reply)?; - ws_tx.lock().await.send(Message::Text(json)).await?; - Ok(()) -} - -/// Handle `/models` or `/agents` text commands for gateway platforms. -/// Returns the response message, or None if the command was not recognized. -/// -/// Supported syntax: -/// /model list — numbered list of available models -/// /model set — switch by exact name or number -/// /models — alias of /model list -/// /agent list — numbered list of available agents -/// /agent set — switch by exact name or number -/// /agents — alias of /agent list -async fn handle_config_command( - trimmed: &str, - router: &AdapterRouter, - thread_key: &str, -) -> Option { - // Parse command: /model or /models (alias) - let (category, label, action, arg) = if trimmed == "/models" { - ("model", "model", "list", "") - } else if trimmed == "/agents" { - ("agent", "agent", "list", "") - } else if trimmed.starts_with("/model ") { - let rest = trimmed.strip_prefix("/model ").unwrap().trim(); - let (action, arg) = rest.split_once(' ').unwrap_or((rest, "")); - ("model", "model", action, arg.trim()) - } else if trimmed.starts_with("/agent ") { - let rest = trimmed.strip_prefix("/agent ").unwrap().trim(); - let (action, arg) = rest.split_once(' ').unwrap_or((rest, "")); - ("agent", "agent", action, arg.trim()) - } else if trimmed == "/model" { - ("model", "model", "list", "") - } else if trimmed == "/agent" { - ("agent", "agent", "list", "") - } else { - return None; - }; - - // Support both "agent" and "mode" categories (kiro-cli vs cursor-agent) - let categories: &[&str] = if category == "agent" { - &["agent", "mode"] - } else { - &[category] - }; - - let options = router.pool().get_config_options(thread_key).await; - let filtered: Vec<_> = options - .iter() - .filter(|o| { - o.category - .as_deref() - .is_some_and(|c| categories.contains(&c)) - }) - .collect(); - - if filtered.is_empty() { - return Some(format!( - "⚠️ No {label} options available. Start a conversation first." - )); - } - // Collect all values with index for numbered list / set-by-number - let mut all_values: Vec<(String, String, String, bool)> = Vec::new(); // (config_id, value, name, is_current) - for opt in &filtered { - for v in &opt.options { - all_values.push(( - opt.id.clone(), - v.value.clone(), - v.name.clone(), - v.value == opt.current_value, - )); + async fn request_attachment_materialization( + &self, + channel: &ChannelRef, + reference: &str, + ) -> Result { + let request_id = format!("req_{}", uuid::Uuid::new_v4()); + let (pending_tx, pending_rx) = tokio::sync::oneshot::channel(); + { + let mut pending = self.pending.lock().await; + if !self.connection_active.load(AtomicOrdering::Acquire) { + anyhow::bail!("attachment materialization connection is unavailable"); + } + pending.insert(request_id.clone(), pending_tx); + } + let _pending_cleanup = PendingGatewayRequest { + pending: self.pending.clone(), + request_id: request_id.clone(), + }; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: channel.origin_event_id.clone().unwrap_or_default(), + platform: channel.platform.clone(), + channel: ReplyChannel { + id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + }, + content: ReplyContent { + content_type: "text".into(), + text: String::new(), + }, + command: Some("materialize_attachment".into()), + request_id: Some(request_id.clone()), + quote_message_id: None, + target_message_id: None, + attachment_ref: Some(reference.to_owned()), + persistent_conversation: channel.persistent_conversation.clone(), + }; + let json = serde_json::to_string(&reply)?; + if let Err(error) = self.ws_tx.lock().await.send(Message::Text(json)).await { + self.pending.lock().await.remove(&request_id); + anyhow::bail!("attachment materialization request failed: {error}"); + } + let response = match tokio::time::timeout( + ATTACHMENT_MATERIALIZATION_RESPONSE_TIMEOUT, + pending_rx, + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(_)) => anyhow::bail!("attachment materialization response channel closed"), + Err(_) => { + self.pending.lock().await.remove(&request_id); + anyhow::bail!("attachment materialization response timed out"); + } + }; + if !response.success { + anyhow::bail!( + "attachment materialization rejected: {}", + response.error_code.as_deref().unwrap_or("gateway_rejected") + ); + } + let attachment = response + .attachment + .ok_or_else(|| anyhow::anyhow!("materialization response has no attachment"))?; + if attachment.reference.is_some() || attachment.path.is_some() { + anyhow::bail!("materialization response contains an invalid attachment envelope"); + } + if !matches!(attachment.attachment_type.as_str(), "image" | "text_file") + || attachment.filename.chars().count() > 200 + || attachment.filename.chars().any(char::is_control) + || attachment.mime_type.len() > 128 + || attachment.mime_type.chars().any(char::is_control) + || attachment + .status + .as_ref() + .is_some_and(|status| status.len() > 256 || status.chars().any(char::is_control)) + { + anyhow::bail!("materialization response contains invalid attachment metadata"); + } + let data = { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&attachment.data) + .map_err(|_| anyhow::anyhow!("materialization response has malformed data"))? + }; + if attachment.status.is_some() { + if !data.is_empty() { + anyhow::bail!("rejected materialization response contains payload data"); + } + } else if attachment.size != data.len() as u64 { + anyhow::bail!("materialization response size does not match its payload"); } + Ok(MaterializedAttachment { + attachment_type: attachment.attachment_type, + filename: attachment.filename, + mime_type: attachment.mime_type, + data, + size: attachment.size, + status: attachment.status, + }) } - match action { - "list" => { - let mut lines = vec![format!("🔧 Available {label}s:")]; - for (i, (_, _, name, is_current)) in all_values.iter().enumerate() { - let marker = if *is_current { " ✅" } else { "" }; - lines.push(format!(" {}. {}{}", i + 1, name, marker)); + async fn request_conversation_registration(&self, channel: &ChannelRef) -> Result<()> { + let request_id = format!("req_{}", uuid::Uuid::new_v4()); + let (pending_tx, pending_rx) = tokio::sync::oneshot::channel(); + { + let mut pending = self.pending.lock().await; + if !self.connection_active.load(AtomicOrdering::Acquire) { + anyhow::bail!("conversation registration connection is unavailable"); } - lines.push(format!("\nUsage: /{label} set ")); - Some(lines.join("\n")) + pending.insert(request_id.clone(), pending_tx); + } + let _pending_cleanup = PendingGatewayRequest { + pending: self.pending.clone(), + request_id: request_id.clone(), + }; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: channel.origin_event_id.clone().unwrap_or_default(), + platform: channel.platform.clone(), + channel: ReplyChannel { + id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + }, + content: ReplyContent { + content_type: "text".into(), + text: String::new(), + }, + command: Some("register_conversation".into()), + request_id: Some(request_id.clone()), + quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: channel.persistent_conversation.clone(), + }; + let json = serde_json::to_string(&reply)?; + if let Err(error) = self.ws_tx.lock().await.send(Message::Text(json)).await { + self.pending.lock().await.remove(&request_id); + return Err(unknown_write_failure( + "conversation_registration_send_failed", + error.to_string(), + )); } - "set" => { - if arg.is_empty() { - return Some(format!("Usage: /{label} set ")); + let response = match tokio::time::timeout(self.ack_timeout, pending_rx).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + return Err(unknown_write_failure( + "conversation_registration_ack_closed", + "conversation registration ACK channel closed", + )) } - // Try number first - if let Ok(num) = arg.parse::() { - if num >= 1 && num <= all_values.len() { - let (ref config_id, ref value, ref name, _) = all_values[num - 1]; - return match router - .pool() - .set_config_option(thread_key, config_id, value) - .await - { - Ok(_) => Some(format!("✅ Switched to **{name}**")), - Err(e) => Some(format!("❌ Failed to switch: {e}")), - }; - } else { - return Some(format!("⚠️ Invalid number. Use 1–{}.", all_values.len())); - } + Err(_) => { + self.pending.lock().await.remove(&request_id); + return Err(unknown_write_failure( + "conversation_registration_ack_timeout", + "conversation registration ACK timed out", + )); } - // Exact match on value or name - let arg_lower = arg.to_lowercase(); - for (config_id, value, name, _) in &all_values { - if value.to_lowercase() == arg_lower || name.to_lowercase() == arg_lower { - return match router - .pool() - .set_config_option(thread_key, config_id, value) - .await - { - Ok(_) => Some(format!("✅ Switched to **{name}**")), - Err(e) => Some(format!("❌ Failed to switch: {e}")), - }; - } + }; + match response.write_outcome() { + WriteOutcome::Delivered { .. } => Ok(()), + outcome @ (WriteOutcome::Rejected { .. } | WriteOutcome::Unknown { .. }) => { + Err(write_failure(outcome)) } - Some(format!( - "⚠️ No {label} matching \"{arg}\". Use /{label} list to see options." - )) } - _ => Some(format!( - "Unknown action \"{action}\". Usage: /{label} list | /{label} set " - )), } } @@ -499,7 +1444,57 @@ impl ChatAdapter for GatewayAdapter { } fn message_limit(&self) -> usize { - 4096 // Telegram limit + 4096 // Legacy conservative limit; negotiated capabilities are platform-aware. + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + self.resolved_capabilities(platform) + } + + async fn materialize_attachment( + &self, + channel: &ChannelRef, + reference: &str, + ) -> Result { + let (negotiated, capabilities) = self.resolved_capabilities_with_mode(&channel.platform); + if !negotiated + || !self.capability_state.topology_supported() + || !capabilities.supports_attachment_materialization + { + anyhow::bail!("attachment materialization capability is unavailable"); + } + if channel + .origin_event_id + .as_deref() + .is_none_or(|event_id| event_id.trim().is_empty()) + || reference.trim().is_empty() + || reference.len() > 128 + || !reference + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + anyhow::bail!("attachment materialization route is unavailable"); + } + self.request_attachment_materialization(channel, reference) + .await + } + + async fn register_conversation(&self, channel: &ChannelRef) -> Result<()> { + let (negotiated, capabilities) = self.resolved_capabilities_with_mode(&channel.platform); + if !negotiated + || !self.capability_state.topology_supported() + || !capabilities.supports_conversation_registry + { + anyhow::bail!("conversation registry capability is unavailable"); + } + if channel + .origin_event_id + .as_deref() + .is_none_or(|event_id| event_id.trim().is_empty()) + { + anyhow::bail!("conversation registration requires an origin event"); + } + self.request_conversation_registration(channel).await } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { @@ -521,12 +1516,20 @@ impl ChatAdapter for GatewayAdapter { _trigger_msg: &MessageRef, title: &str, ) -> Result { + if channel.persistent_conversation.is_some() { + return Err(write_failure(WriteOutcome::Rejected { + code: "persistent_thread_unsupported".into(), + message: "Teams persistent conversations do not support synthetic threads".into(), + retry_after_ms: None, + })); + } // Send create_topic command to gateway let req_id = format!("req_{}", uuid::Uuid::new_v4()); let (tx, rx) = tokio::sync::oneshot::channel(); self.pending.lock().await.insert(req_id.clone(), tx); let reply = GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), reply_to: String::new(), platform: channel.platform.clone(), @@ -541,6 +1544,8 @@ impl ChatAdapter for GatewayAdapter { command: Some("create_topic".into()), request_id: Some(req_id.clone()), quote_message_id: None, + target_message_id: None, + persistent_conversation: channel.persistent_conversation.clone(), }; let json = serde_json::to_string(&reply)?; self.ws_tx.lock().await.send(Message::Text(json)).await?; @@ -552,6 +1557,7 @@ impl ChatAdapter for GatewayAdapter { channel_id: channel.channel_id.clone(), thread_id: resp.thread_id, parent_id: None, + persistent_conversation: channel.persistent_conversation.clone(), origin_event_id: channel.origin_event_id.clone(), }), Ok(Ok(resp)) => { @@ -567,47 +1573,12 @@ impl ChatAdapter for GatewayAdapter { } async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: msg.message_id.clone(), - platform: msg.channel.platform.clone(), - channel: ReplyChannel { - id: msg.channel.channel_id.clone(), - thread_id: msg.channel.thread_id.clone(), - }, - content: ReplyContent { - content_type: "text".into(), - text: emoji.into(), - }, - command: Some("add_reaction".into()), - quote_message_id: None, - request_id: None, - }; - let json = serde_json::to_string(&reply)?; - self.ws_tx.lock().await.send(Message::Text(json)).await?; - Ok(()) + self.send_reaction_command(msg, emoji, "add_reaction").await } async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: msg.message_id.clone(), - platform: msg.channel.platform.clone(), - channel: ReplyChannel { - id: msg.channel.channel_id.clone(), - thread_id: msg.channel.thread_id.clone(), - }, - content: ReplyContent { - content_type: "text".into(), - text: emoji.into(), - }, - command: Some("remove_reaction".into()), - quote_message_id: None, - request_id: None, - }; - let json = serde_json::to_string(&reply)?; - self.ws_tx.lock().await.send(Message::Text(json)).await?; - Ok(()) + self.send_reaction_command(msg, emoji, "remove_reaction") + .await } async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { @@ -617,12 +1588,27 @@ impl ChatAdapter for GatewayAdapter { // signals — cosmetic streaming would keep flushing forever and the final // edit fallback to send_message could not trigger. // - // Scope intentionally limited to platforms that ack writes (see - // EDIT_RESPONSE_PLATFORMS). Other adapters (LINE, Teams, Slack, Discord, - // …) keep the original fire-and-forget path so cosmetic streaming on - // those platforms does not pay a response-wait penalty per flush. - const EDIT_RESPONSE_TIMEOUT_MS: u64 = 800; - let needs_response = self.streaming && platform_acks_writes(&msg.channel.platform); + // Only negotiated/legacy capabilities that explicitly advertise edit + // acknowledgements pay the response-wait cost. + const LEGACY_EDIT_RESPONSE_TIMEOUT_MS: u64 = 800; + let (negotiated, capabilities) = + self.resolved_capabilities_with_mode(&msg.channel.platform); + self.ensure_persistent_write_available(&msg.channel, negotiated, &capabilities)?; + if msg.channel.persistent_conversation.is_some() && !capabilities.edit_ack { + return Err(write_failure(WriteOutcome::Rejected { + code: "persistent_edit_unavailable".into(), + message: "Gateway persistent edit is unavailable".into(), + retry_after_ms: None, + })); + } + let required_ack = negotiated && capabilities.edit_ack; + let needs_response = + required_ack || (!negotiated && self.streaming && capabilities.edit_ack); + let response_timeout = if required_ack { + self.ack_timeout + } else { + std::time::Duration::from_millis(LEGACY_EDIT_RESPONSE_TIMEOUT_MS) + }; let req_id = if needs_response { Some(format!("req_{}", uuid::Uuid::new_v4())) @@ -636,9 +1622,11 @@ impl ChatAdapter for GatewayAdapter { } else { None }; + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), - reply_to: msg.message_id.clone(), + reply_to, platform: msg.channel.platform.clone(), channel: ReplyChannel { id: msg.channel.channel_id.clone(), @@ -650,42 +1638,65 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("edit_message".into()), quote_message_id: None, + target_message_id, request_id: req_id.clone(), + persistent_conversation: msg.channel.persistent_conversation.clone(), }; let json = serde_json::to_string(&reply)?; if let Err(e) = self.ws_tx.lock().await.send(Message::Text(json)).await { if let Some(ref id) = req_id { self.pending.lock().await.remove(id); } - return Err(e.into()); + return Err(unknown_write_failure( + "gateway_edit_send_failed", + e.to_string(), + )); } if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { - match tokio::time::timeout( - std::time::Duration::from_millis(EDIT_RESPONSE_TIMEOUT_MS), - rx, - ).await { - Ok(Ok(resp)) if resp.success => Ok(()), - Ok(Ok(resp)) => { - let err_msg = resp.error.clone() - .unwrap_or_else(|| "gateway reported edit failure".to_string()); - tracing::warn!(request_id = %id, error = %err_msg, "edit_message gateway replied failure"); - Err(anyhow::anyhow!("edit failure: {err_msg}")) - } + match tokio::time::timeout(response_timeout, rx).await { + Ok(Ok(resp)) => match resp.write_outcome() { + WriteOutcome::Delivered { .. } => Ok(()), + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected edit"); + Err(write_failure(WriteOutcome::Rejected { + code, + message, + retry_after_ms, + })) + } + WriteOutcome::Unknown { code, message } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway edit outcome unknown"); + Err(write_failure(WriteOutcome::Unknown { code, message })) + } + }, + Ok(Err(_)) if required_ack => Err(unknown_write_failure( + "edit_ack_channel_closed", + "required edit ACK channel closed", + )), Ok(Err(_)) => { - tracing::debug!(request_id = %id, "edit_message gateway response channel closed"); + tracing::debug!(request_id = %id, "legacy edit response channel closed"); Ok(()) } + Err(_) if required_ack => { + self.pending.lock().await.remove(id); + Err(unknown_write_failure( + "edit_ack_timeout", + "required edit ACK timed out", + )) + } Err(_) => { - // Timeout — feishu didn't respond within the window - // (probably a slow API). Treat as success to avoid - // false-positive ❌; the cap-reached path already short- - // circuits much faster (gateway returns immediately). + // Legacy Feishu used a short best-effort observation window; + // preserve that behavior when no capability was negotiated. self.pending.lock().await.remove(id); Ok(()) } } } else { - // Non-feishu (or non-streaming): fire-and-forget, no added latency. + // An unadvertised edit remains fire-and-forget with no added latency. Ok(()) } } @@ -698,16 +1709,33 @@ impl ChatAdapter for GatewayAdapter { /// to avoid duplicated content. The default zero-width-edit fallback would /// itself fail on a cap-reached message, leaving the placeholder visible. /// - /// Fire-and-forget: gateway adapters that don't implement delete will simply - /// ignore the command. Failure is non-fatal — if delete fails, the user sees - /// the placeholder remain (same behavior as before this override). We do not - /// wait on a response here: the recovery path sends fresh content regardless - /// of whether the delete landed, so a response would only buy an extra log - /// line at the cost of a per-finalize wait. + /// Legacy peers remain fire-and-forget. A negotiated peer is awaited only + /// when it explicitly advertises `delete_ack`. async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + let (negotiated, capabilities) = + self.resolved_capabilities_with_mode(&msg.channel.platform); + self.ensure_persistent_write_available(&msg.channel, negotiated, &capabilities)?; + if msg.channel.persistent_conversation.is_some() && !capabilities.delete_ack { + return Err(write_failure(WriteOutcome::Rejected { + code: "persistent_delete_unavailable".into(), + message: "Gateway persistent delete is unavailable".into(), + retry_after_ms: None, + })); + } + let required_ack = negotiated && capabilities.delete_ack; + let request_id = required_ack.then(|| format!("req_{}", uuid::Uuid::new_v4())); + let pending_rx = if let Some(ref id) = request_id { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.pending.lock().await.insert(id.clone(), tx); + Some(rx) + } else { + None + }; + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), - reply_to: msg.message_id.clone(), + reply_to, platform: msg.channel.platform.clone(), channel: ReplyChannel { id: msg.channel.channel_id.clone(), @@ -719,19 +1747,67 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("delete_message".into()), quote_message_id: None, - request_id: None, + target_message_id, + request_id: request_id.clone(), + persistent_conversation: msg.channel.persistent_conversation.clone(), }; let json = serde_json::to_string(&reply)?; - self.ws_tx.lock().await.send(Message::Text(json)).await?; - Ok(()) + if let Err(error) = self.ws_tx.lock().await.send(Message::Text(json)).await { + if let Some(ref id) = request_id { + self.pending.lock().await.remove(id); + } + return Err(unknown_write_failure( + "gateway_delete_send_failed", + error.to_string(), + )); + } + + let (Some(rx), Some(id)) = (pending_rx, request_id) else { + return Ok(()); + }; + match tokio::time::timeout(self.ack_timeout, rx).await { + Ok(Ok(response)) => match response.write_outcome() { + WriteOutcome::Delivered { .. } => Ok(()), + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected delete"); + Err(write_failure(WriteOutcome::Rejected { + code, + message, + retry_after_ms, + })) + } + WriteOutcome::Unknown { code, message } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway delete outcome unknown"); + Err(write_failure(WriteOutcome::Unknown { code, message })) + } + }, + Ok(Err(_)) => Err(unknown_write_failure( + "delete_ack_channel_closed", + "required delete ACK channel closed", + )), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(unknown_write_failure( + "delete_ack_timeout", + "required delete ACK timed out", + )) + } + } } fn use_streaming(&self, _other_bot_present: bool) -> bool { - self.streaming + self.resolved_capabilities(self.platform_name) + .streaming_mode + != StreamingMode::Disabled } fn show_streaming_placeholder(&self) -> bool { - self.streaming_placeholder + self.resolved_capabilities(self.platform_name) + .show_streaming_placeholder } fn renders_native_tables(&self, _platform: &str) -> bool { @@ -759,7 +1835,12 @@ pub struct GatewayParams { pub streaming: bool, pub streaming_placeholder: bool, pub telegram_rich_messages: bool, + pub teams_processing_indicator: bool, + pub teams_streaming: bool, + pub teams_inbound_attachments: bool, + pub gateway_ack_timeout_secs: u64, pub stt: crate::config::SttConfig, + pub teams_scope_policy: TeamsScopePolicy, } pub async fn run_gateway_adapter( @@ -767,31 +1848,31 @@ pub async fn run_gateway_adapter( mut shutdown_rx: tokio::sync::watch::Receiver, dispatcher: Arc, router: Arc, + adapter_proxy: Arc, #[cfg(feature = "filestore")] filestore: Option>, ) -> Result<()> { - let platform: &'static str = Box::leak(params.platform.into_boxed_str()); + if adapter_proxy.platform_name != params.platform { + anyhow::bail!("Gateway adapter proxy platform does not match configuration"); + } + let platform = adapter_proxy.platform_name; // Append auth token as query param if configured let gateway_url = params.url; let bot_username = params.bot_username; let allow_bot_messages = params.allow_bot_messages; let trusted_bot_ids: HashSet = params.trusted_bot_ids.into_iter().collect(); - // Cosmetic streaming edits a placeholder in place. On platforms without an - // edit API (e.g. LINE) every edit lands as a new message — growing - // duplicates — so force send-once mode there regardless of config. - let streaming = if params.streaming && !platform_supports_streaming(platform) { - warn!( - platform, - "streaming is enabled but this platform cannot edit messages; \ - forcing send-once mode to avoid duplicate messages" - ); - false - } else { - params.streaming - }; + // The platform-aware capability contract decides whether configured + // streaming is usable. Legacy peers resolve through the conservative + // fallback; negotiated peers supply this over the hello exchange. + let streaming = params.streaming; let streaming_placeholder = params.streaming_placeholder; let telegram_rich_messages = params.telegram_rich_messages; + let teams_processing_indicator = params.teams_processing_indicator; + let teams_streaming = params.teams_streaming; + let teams_inbound_attachments = params.teams_inbound_attachments; + let gateway_ack_timeout_secs = params.gateway_ack_timeout_secs; let stt_config = params.stt; + let teams_scope_policy = params.teams_scope_policy; let connect_url = match ¶ms.token { Some(token) => { @@ -815,7 +1896,18 @@ pub async fn run_gateway_adapter( info!(url = %gateway_url, "connecting to custom gateway"); - let ws_stream = match tokio_tungstenite::connect_async(&connect_url).await { + let ws_config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig { + max_message_size: Some(GATEWAY_WS_MESSAGE_LIMIT), + max_frame_size: Some(GATEWAY_WS_MESSAGE_LIMIT), + ..Default::default() + }; + let ws_stream = match tokio_tungstenite::connect_async_with_config( + &connect_url, + Some(ws_config), + false, + ) + .await + { Ok((stream, _)) => { backoff_secs = 1; // reset on success info!("connected to gateway"); @@ -835,15 +1927,30 @@ pub async fn run_gateway_adapter( let (ws_tx, mut ws_rx) = ws_stream.split(); let ws_tx: SharedWsTx = Arc::new(Mutex::new(ws_tx)); let pending: PendingRequests = Arc::new(Mutex::new(HashMap::new())); + let connection_active = Arc::new(AtomicBool::new(true)); + let capability_state = Arc::new(GatewayCapabilityState::default()); + let client_hello = build_client_hello(); + let hello_json = serde_json::to_string(&client_hello)?; + if let Err(error) = ws_tx.lock().await.send(Message::Text(hello_json)).await { + warn!(error = %error, "failed to send optional gateway hello; continuing in legacy mode"); + } let adapter: Arc = Arc::new(GatewayAdapter::new( ws_tx.clone(), pending.clone(), - platform, - streaming, - streaming_placeholder, - telegram_rich_messages, + connection_active.clone(), + capability_state.clone(), + GatewayAdapterOptions { + platform_name: platform, + streaming, + streaming_placeholder, + telegram_rich_messages, + teams_processing_indicator, + teams_streaming, + teams_inbound_attachments, + gateway_ack_timeout_secs, + }, )); - let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses + let adapter_generation = adapter_proxy.install(adapter.clone()); let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); // Hoist filter params outside loop — all fields are loop-invariant. @@ -861,6 +1968,20 @@ pub async fn run_gateway_adapter( trusted_bot_ids: &trusted_bot_ids, bot_username: bot_username.as_deref(), }; + let teams_event_context = Arc::new(GatewayEventContext { + adapter: adapter.clone(), + dispatcher: dispatcher.clone(), + router: router.clone(), + allow_bot_messages, + trusted_bot_ids: trusted_bot_ids.clone(), + bot_username: bot_username.clone(), + stt_config: stt_config.clone(), + teams_scope_policy: teams_scope_policy.clone(), + teams_inbound_attachments, + #[cfg(feature = "filestore")] + filestore: filestore.clone(), + }); + let teams_event_order = Arc::new(Mutex::new(())); loop { tokio::select! { @@ -869,18 +1990,68 @@ pub async fn run_gateway_adapter( Some(Ok(Message::Text(text))) => { let text_str: &str = &text; + if let Ok(envelope) = serde_json::from_str::(text_str) { + if envelope.schema == GATEWAY_HELLO_SCHEMA { + match serde_json::from_str::(text_str) { + Ok(hello) + if hello.schema == GATEWAY_HELLO_SCHEMA + && hello.protocol_version == GATEWAY_PROTOCOL_VERSION => { + if !hello.topology.supported { + warn!( + active_consumers = hello.topology.active_consumers, + delivery_mode = %hello.topology.delivery_mode, + "gateway reports unsupported multi-consumer topology" + ); + } + info!( + protocol_version = hello.protocol_version, + capability_count = hello.capabilities.len(), + "gateway capabilities negotiated" + ); + capability_state.update(hello); + } + Ok(hello) => { + warn!( + peer_version = hello.protocol_version, + supported_version = GATEWAY_PROTOCOL_VERSION, + "gateway hello version is unsupported; continuing in legacy mode" + ); + } + Err(error) => { + warn!(error = %error, "invalid gateway hello; continuing in legacy mode"); + } + } + continue; + } + } + // Check if it's a response to a pending command if let Ok(resp) = serde_json::from_str::(text_str) { - if resp.schema == "openab.gateway.response.v1" { - if let Some(tx) = pending.lock().await.remove(&resp.request_id) { - let _ = tx.send(resp); + if resp.schema == "openab.gateway.response.v1" { + if let Some(tx) = pending.lock().await.remove(&resp.request_id) { + let _ = tx.send(resp); + } + continue; } - continue; } - } match serde_json::from_str::(text_str) { Ok(event) => { + if event.platform.eq_ignore_ascii_case("teams") { + // Teams registration waits for a correlated GatewayResponse, + // so the whole event must run outside this WebSocket reader. + // This also guarantees registration completes before command, + // attachment, session, or agent side effects begin. + let serialize = !event.content.attachments.is_empty(); + spawn_teams_gateway_event( + &mut tasks, + text_str.to_owned(), + teams_event_context.clone(), + teams_event_order.clone(), + serialize, + ); + continue; + } if should_skip_event(&event, &filter) { continue; } @@ -895,7 +2066,7 @@ pub async fn run_gateway_adapter( // that only this loop can dispatch, so an // inline await would stall all event // processing for the reply timeout. - match gate_gateway_event(&router, &event) { + match gate_gateway_event(&router, &event, &teams_scope_policy) { GateOutcome::Allow => {} GateOutcome::Deny { echo } => { if let Some((echo_channel, msg)) = echo { @@ -910,21 +2081,41 @@ pub async fn run_gateway_adapter( } } - info!( - platform = %event.platform, - sender = %event.sender.name, - channel = %redact_channel(&event.channel.id), - "gateway event received" - ); + let prompt = strip_recipient_mention(&event); let channel = ChannelRef { platform: event.platform.clone(), channel_id: event.channel.id.clone(), thread_id: event.channel.thread_id.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: Some(event.event_id.clone()), }; + if let Some(command) = parse_command(&prompt) { + let context = gateway_command_context(&event); + let service = CommandService::new( + router.pool().clone(), + dispatcher.clone(), + ); + spawn_gateway_command( + &mut tasks, + command, + context, + service, + adapter.clone(), + channel, + ); + continue; + } + + info!( + platform = %event.platform, + sender = %event.sender.name, + channel = %redact_channel(&event.channel.id), + "gateway event received" + ); + let sender_ctx = SenderContext { schema: "openab.sender.v1".into(), sender_id: event.sender.id.clone(), @@ -941,7 +2132,7 @@ pub async fn run_gateway_adapter( event.timestamp.clone() }), message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, - receiver_id: None, // gateway does not yet resolve receiver identity + receiver_id: event.recipient.as_ref().map(|recipient| recipient.id.clone()), }; let sender_json = serde_json::to_string(&sender_ctx) .unwrap_or_default(); @@ -952,7 +2143,6 @@ pub async fn run_gateway_adapter( }; let adapter = adapter.clone(); - let prompt = event.content.text.clone(); let sender_name = event.sender.name.clone(); let sender_id = event.sender.id.clone(); let dispatcher = dispatcher.clone(); @@ -1112,40 +2302,9 @@ pub async fn run_gateway_adapter( } } - // Slash command interception for gateway platforms - // (Feishu/LINE/Telegram don't have native slash commands) - // Use fire-and-forget send — slash command responses don't - // need message_id for streaming edits. - let trimmed = prompt.trim(); - if trimmed == "/reset" { - let thread_id_str = event.channel.thread_id.as_deref().unwrap_or(&event.channel.id); - let thread_key = format!("{}:{}", event.platform, thread_id_str); - let dropped = dispatcher.cancel_buffered_thread(event.platform.as_str(), thread_id_str); - let msg = match (router.pool().reset_session(&thread_key).await, dropped) { - (Ok(()), 0) => "🔄 Session reset. Start a new conversation!".to_string(), - (Ok(()), n) => format!("🔄 Session reset. Dropped {n} buffered message(s). Start a new conversation!"), - (Err(_), 0) => "⚠️ No active session to reset.".to_string(), - (Err(_), n) => format!("🔄 Dropped {n} buffered message(s). No active session to reset."), - }; - let _ = send_fire_and_forget(&slash_ws_tx, &channel, &msg).await; + if prompt.is_empty() && extra_blocks.is_empty() { continue; } - if trimmed == "/cancel" { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); - let msg = match router.pool().cancel_session(&thread_key).await { - Ok(()) => "🛑 Cancel signal sent.".to_string(), - Err(e) => format!("⚠️ {e}"), - }; - let _ = send_fire_and_forget(&slash_ws_tx, &channel, &msg).await; - continue; - } - { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); - if let Some(msg) = handle_config_command(trimmed, &router, &thread_key).await { - let _ = send_fire_and_forget(&slash_ws_tx, &channel, &msg).await; - continue; - } - } tasks.spawn(async move { // If supergroup with no thread_id, create a forum topic @@ -1209,8 +2368,16 @@ pub async fn run_gateway_adapter( _ => {} } } + completed = tasks.join_next(), if !tasks.is_empty() => { + if let Some(Err(error)) = completed { + warn!(error = %error, "gateway event task failed"); + } + } _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { + adapter_proxy.clear_generation(adapter_generation); + connection_active.store(false, AtomicOrdering::Release); + pending.lock().await.clear(); info!("gateway adapter shutting down, waiting for {} in-flight tasks", tasks.len()); while tasks.join_next().await.is_some() {} return Ok(()); @@ -1219,6 +2386,12 @@ pub async fn run_gateway_adapter( } } // inner loop — break here means reconnect + // Stop new proxy calls and wake requests owned by this generation. A + // later generation cannot clear or satisfy any of these waiters. + adapter_proxy.clear_generation(adapter_generation); + connection_active.store(false, AtomicOrdering::Release); + pending.lock().await.clear(); + // Drain in-flight tasks before reconnecting while tasks.join_next().await.is_some() {} @@ -1235,6 +2408,7 @@ pub async fn run_gateway_adapter( /// Context required to process a gateway event without a WebSocket connection. /// Used by the unified binary to dispatch webhook events directly. +#[derive(Clone)] pub struct GatewayEventContext { pub adapter: Arc, pub dispatcher: Arc, @@ -1243,6 +2417,8 @@ pub struct GatewayEventContext { pub trusted_bot_ids: HashSet, pub bot_username: Option, pub stt_config: crate::config::SttConfig, + pub teams_scope_policy: TeamsScopePolicy, + pub teams_inbound_attachments: bool, #[cfg(feature = "filestore")] pub filestore: Option>, } @@ -1265,7 +2441,9 @@ const ECHO_WINDOW: std::time::Duration = std::time::Duration::from_secs(300); /// Returns true if an echo to `key` is allowed now (and records the timestamp). fn echo_allowed(key: &str) -> bool { let now = std::time::Instant::now(); - let mut map = ECHO_THROTTLE.lock().unwrap(); + let mut map = ECHO_THROTTLE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); match map.get(key) { Some(prev) if now.duration_since(*prev) < ECHO_WINDOW => false, _ => { @@ -1298,14 +2476,39 @@ enum GateOutcome { /// `DenyScope` (and any future variant), denies silently — scope is not a /// security boundary, so no echo. /// -/// Phase 1: `is_dm = false` preserves today's behavior where gateway DMs are -/// evaluated against the channel allowlist like any other channel (the -/// `allow_dm` surface semantics arrive with the per-platform trust flip). -/// TODO(phase-2): derive is_dm from the event/ChannelRef carrier so the -/// `allow_dm` L2 surface can be enforced and tested for gateway platforms. -fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEvent) -> GateOutcome { - let decision = - router.gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id); +/// Teams events carrying authenticated typed scope use the Teams-specific L2 +/// policy and then the shared L3 identity gate. Events from old peers without +/// `scope` retain the legacy conversation-ID / `is_dm = false` behavior. +fn gate_gateway_event( + router: &crate::adapter::AdapterRouter, + event: &GatewayEvent, + teams_scope_policy: &TeamsScopePolicy, +) -> GateOutcome { + let decision = if event.platform.eq_ignore_ascii_case("teams") { + match event.scope.as_ref() { + Some(scope) + if teams_scope_policy.surface_allowed( + &event.channel.id, + &event.channel.channel_type, + scope, + ) => + { + router.gate_identity(&event.platform, &event.sender.id) + } + Some(_) => crate::trust::Decision::DenyScope, + None => { + if !teams_scope_policy.uses_legacy_fallback() { + tracing::warn!( + "gateway: Teams event has no typed scope; using legacy conversation-ID \ + fallback for rolling compatibility" + ); + } + router.gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id) + } + } + } else { + router.gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id) + }; match decision { crate::trust::Decision::Allow => GateOutcome::Allow, crate::trust::Decision::DenyIdentity => { @@ -1325,6 +2528,7 @@ fn gate_gateway_event(router: &crate::adapter::AdapterRouter, event: &GatewayEve channel_id: event.channel.id.clone(), thread_id: event.channel.thread_id.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: Some(event.event_id.clone()), }; let msg = format!( @@ -1380,7 +2584,7 @@ pub async fn process_gateway_event( // Shared ingress trust gate (L2 scope + L3 identity), keyed by platform. // Awaiting echo delivery here is safe: this runs on the axum/bridge task, // not inside the WS event loop. - match gate_gateway_event(&ctx.router, &event) { + match gate_gateway_event(&ctx.router, &event, &ctx.teams_scope_policy) { GateOutcome::Allow => {} GateOutcome::Deny { echo } => { if let Some((echo_channel, msg)) = echo { @@ -1390,21 +2594,38 @@ pub async fn process_gateway_event( } } - tracing::info!( - platform = %event.platform, - sender = %event.sender.name, - channel = %redact_channel(&event.channel.id), - "gateway event received (unified)" - ); + let prompt = strip_recipient_mention(&event); let channel = ChannelRef { platform: event.platform.clone(), channel_id: event.channel.id.clone(), thread_id: event.channel.thread_id.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: Some(event.event_id.clone()), }; + register_trusted_gateway_conversation( + ctx.adapter.clone(), + channel.clone(), + trusted_conversation_registration_allowed(&event), + ) + .await; + + if let Some(command) = parse_command(&prompt) { + let context = gateway_command_context(&event); + let service = CommandService::new(ctx.router.pool().clone(), ctx.dispatcher.clone()); + execute_gateway_command(command, context, service, ctx.adapter.clone(), channel).await; + return Ok(false); + } + + tracing::info!( + platform = %event.platform, + sender = %event.sender.name, + channel = %redact_channel(&event.channel.id), + "gateway event received (unified)" + ); + let sender_ctx = SenderContext { schema: "openab.sender.v1".into(), sender_id: event.sender.id.clone(), @@ -1420,7 +2641,10 @@ pub async fn process_gateway_event( event.timestamp.clone() }), message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, - receiver_id: None, + receiver_id: event + .recipient + .as_ref() + .map(|recipient| recipient.id.clone()), }; let sender_json = serde_json::to_string(&sender_ctx).unwrap_or_default(); @@ -1429,9 +2653,83 @@ pub async fn process_gateway_event( message_id: event.message_id.clone(), }; - // Convert gateway attachments to ContentBlocks + // Convert gateway attachments to ContentBlocks. Teams references are + // resolved only here, after the authoritative structural + L2 + L3 gate. let mut extra_blocks = Vec::new(); - for att in &event.content.attachments { + let teams_event = event.platform.eq_ignore_ascii_case("teams"); + let attachment_limit = if teams_event { 10 } else { usize::MAX }; + let attachment_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45); + let attachment_capabilities = ctx.adapter.capabilities(&event.platform); + for metadata in event.content.attachments.iter().take(attachment_limit) { + if teams_event && !ctx.teams_inbound_attachments { + continue; + } + let mut att = metadata.clone(); + let mut materialized_data = None; + if teams_event + && (att.attachment_type.len() > 32 + || att.attachment_type.chars().any(char::is_control) + || att.filename.chars().count() > 200 + || att.filename.chars().any(char::is_control) + || att.mime_type.len() > 128 + || att.mime_type.chars().any(char::is_control) + || att.status.as_ref().is_some_and(|status| { + status.len() > 256 || status.chars().any(char::is_control) + })) + { + continue; + } + let reference = att.reference.take(); + // Teams never accepts pre-materialized bytes or a Gateway-local path. + // A no-reference entry is usable only as bounded rejected metadata. + if teams_event && reference.is_none() && att.status.is_none() { + continue; + } + if let Some(reference) = reference { + if !teams_event || !attachment_capabilities.supports_attachment_materialization { + continue; + } + if tokio::time::Instant::now() >= attachment_deadline { + att.status = + Some("download failed: attachment materialization batch timed out".into()); + } else { + match tokio::time::timeout_at( + attachment_deadline, + ctx.adapter.materialize_attachment(&channel, &reference), + ) + .await + { + Ok(Ok(materialized)) => { + att.attachment_type = materialized.attachment_type; + att.filename = materialized.filename; + att.mime_type = materialized.mime_type; + att.size = materialized.size; + att.path = None; + att.data.clear(); + att.status = materialized.status; + if att.status.is_none() + && att.attachment_type == "text_file" + && std::str::from_utf8(&materialized.data).is_err() + { + att.status = + Some("invalid content: text attachment is not valid UTF-8".into()); + } else { + materialized_data = Some(materialized.data); + } + } + Ok(Err(_)) => { + att.status = + Some("download failed: attachment materialization failed".into()); + } + Err(_) => { + att.status = Some( + "download failed: attachment materialization batch timed out".into(), + ); + } + } + } + } + if let Some(ref reason) = att.status { let size_str = format_size(att.size); extra_blocks.push(ContentBlock::Text { @@ -1443,7 +2741,9 @@ pub async fn process_gateway_event( continue; } - let bytes_result = if let Some(ref path) = att.path { + let bytes_result = if let Some(bytes) = materialized_data { + Ok(bytes) + } else if let Some(ref path) = att.path { tokio::fs::read(path).await.map_err(|e| e.to_string()) } else if !att.data.is_empty() { use base64::Engine; @@ -1556,135 +2856,1215 @@ pub async fn process_gateway_event( } } } - _ => {} + _ => {} + } + } + + if prompt.is_empty() && extra_blocks.is_empty() { + return Ok(false); + } + + // Submit to dispatcher + let adapter = ctx.adapter.clone(); + let dispatcher = ctx.dispatcher.clone(); + let sender_name = event.sender.name.clone(); + let sender_id = event.sender.id.clone(); + + tokio::spawn(async move { + let thread_channel = if event.channel.channel_type == "supergroup" + && channel.thread_id.is_none() + { + let title = crate::format::shorten_thread_name(&prompt); + match adapter.create_thread(&channel, &trigger_msg, &title).await { + Ok(tc) => tc, + Err(e) => { + tracing::warn!("create_thread failed, replying in channel: {e}"); + channel.clone() + } + } + } else { + channel.clone() + }; + + let thread_id = thread_channel + .thread_id + .as_deref() + .unwrap_or(&thread_channel.channel_id); + let thread_key = dispatcher.key( + &thread_channel.platform, + thread_id, + &sender_id, + ); + let estimated_tokens = + crate::dispatch::estimate_tokens(&prompt, &extra_blocks); + let buf_msg = crate::dispatch::BufferedMessage { + sender_json, + sender_name, + prompt, + extra_blocks, + trigger_msg, + arrived_at: std::time::Instant::now(), + estimated_tokens, + other_bot_present: false, + recipient: None, + }; + if let Err(e) = dispatcher + .submit(thread_key, thread_channel, adapter, buf_msg) + .await + { + tracing::error!("gateway dispatcher submit error: {e}"); + } + }); + + Ok(true) +} + +fn format_size(n: u64) -> String { + if n >= 1024 * 1024 { + format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)) + } else if n >= 1024 { + format!("{:.1} KB", n as f64 / 1024.0) + } else { + format!("{} B", n) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::AdapterRouter; + use crate::commands::CommandName; + use async_trait::async_trait; + use std::collections::{HashMap, HashSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct AttachmentProbeAdapter { + registrations: AtomicUsize, + materializations: AtomicUsize, + sends: AtomicUsize, + registration_fails: AtomicBool, + operations: std::sync::Mutex>, + messages: std::sync::Mutex>, + } + + impl AttachmentProbeAdapter { + fn messages(&self) -> std::sync::MutexGuard<'_, Vec> { + self.messages + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn operations(&self) -> std::sync::MutexGuard<'_, Vec<&'static str>> { + self.operations + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + } + + #[async_trait] + impl ChatAdapter for AttachmentProbeAdapter { + fn platform(&self) -> &'static str { + "probe" + } + + fn message_limit(&self) -> usize { + 4096 + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + AdapterCapabilities { + supports_attachment_materialization: platform == "teams", + supports_conversation_registry: platform == "teams", + ..AdapterCapabilities::default() + } + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + + async fn register_conversation(&self, _channel: &ChannelRef) -> Result<()> { + self.registrations.fetch_add(1, Ordering::SeqCst); + self.operations().push("register"); + if self.registration_fails.load(Ordering::SeqCst) { + anyhow::bail!("synthetic registration failure"); + } + Ok(()) + } + + async fn materialize_attachment( + &self, + _channel: &ChannelRef, + _reference: &str, + ) -> Result { + self.materializations.fetch_add(1, Ordering::SeqCst); + self.operations().push("materialize"); + Ok(MaterializedAttachment { + attachment_type: "text_file".into(), + filename: "notes.txt".into(), + mime_type: "text/plain; charset=utf-8".into(), + data: b"secret bytes".to_vec(), + size: 12, + status: None, + }) + } + + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + self.sends.fetch_add(1, Ordering::SeqCst); + self.operations().push("send"); + self.messages().push(content.to_string()); + Ok(MessageRef { + channel: channel.clone(), + message_id: "echo".into(), + }) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + } + + struct ProxyProbeAdapter { + message_id: &'static str, + persistent_send: bool, + } + + #[async_trait] + impl ChatAdapter for ProxyProbeAdapter { + fn platform(&self) -> &'static str { + "teams" + } + + fn message_limit(&self) -> usize { + 4_096 + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + AdapterCapabilities { + send_ack: platform == "teams" && self.persistent_send, + supports_persistent_conversation_send: platform == "teams" && self.persistent_send, + ..AdapterCapabilities::default() + } + } + + async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result { + Ok(MessageRef { + channel: channel.clone(), + message_id: self.message_id.into(), + }) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + } + + struct OutcomeProbeAdapter { + sends: AtomicUsize, + outcome: WriteOutcome, + } + + #[async_trait] + impl ChatAdapter for OutcomeProbeAdapter { + fn platform(&self) -> &'static str { + "probe" + } + + fn message_limit(&self) -> usize { + 4_096 + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + + async fn send_message(&self, _channel: &ChannelRef, _content: &str) -> Result { + anyhow::bail!("send_message_outcome override must be used") + } + + async fn send_message_outcome( + &self, + _channel: &ChannelRef, + _content: &str, + ) -> WriteOutcome { + self.sends.fetch_add(1, Ordering::SeqCst); + self.outcome.clone() + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + } + + struct BlockingOutcomeAdapter { + sends: AtomicUsize, + started: tokio::sync::Semaphore, + release: tokio::sync::Semaphore, + registration_started: tokio::sync::Semaphore, + registration_release: tokio::sync::Semaphore, + } + + impl Default for BlockingOutcomeAdapter { + fn default() -> Self { + Self { + sends: AtomicUsize::new(0), + started: tokio::sync::Semaphore::new(0), + release: tokio::sync::Semaphore::new(0), + registration_started: tokio::sync::Semaphore::new(0), + registration_release: tokio::sync::Semaphore::new(0), + } + } + } + + #[async_trait] + impl ChatAdapter for BlockingOutcomeAdapter { + fn platform(&self) -> &'static str { + "probe" + } + + fn message_limit(&self) -> usize { + 4_096 + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + AdapterCapabilities { + supports_conversation_registry: platform == "teams", + ..AdapterCapabilities::default() + } + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + + async fn register_conversation(&self, _channel: &ChannelRef) -> Result<()> { + self.registration_started.add_permits(1); + let permit = self + .registration_release + .acquire() + .await + .expect("semaphore open"); + permit.forget(); + Ok(()) + } + + async fn send_message(&self, _channel: &ChannelRef, _content: &str) -> Result { + anyhow::bail!("send_message_outcome override must be used") + } + + async fn send_message_outcome( + &self, + _channel: &ChannelRef, + _content: &str, + ) -> WriteOutcome { + self.sends.fetch_add(1, Ordering::SeqCst); + self.started.add_permits(1); + let permit = self.release.acquire().await.expect("semaphore open"); + permit.forget(); + WriteOutcome::Delivered { + message_id: Some("message".into()), + } + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + } + + fn attachment_event_json(sender_id: &str) -> String { + serde_json::json!({ + "schema": "openab.gateway.event.v1", + "event_id": "event-attachment", + "timestamp": "", + "platform": "teams", + "event_type": "message", + "channel": { + "id": "conversation-1", + "type": "personal", + "thread_id": null + }, + "sender": { + "id": sender_id, + "name": "Attachment User", + "display_name": "Attachment User", + "is_bot": false + }, + "content": { + "type": "text", + "text": "", + "attachments": [{ + "type": "text_file", + "filename": "notes.txt", + "mime_type": "text/plain", + "reference": "att-opaque", + "data": "", + "path": null, + "size": 0, + "status": null + }] + }, + "mentions": [], + "message_id": "activity-1", + "scope": { + "tenant_id": "tenant-1", + "team_id": null, + "channel_id": null, + "conversation_type": "personal", + "trust_scope_id": "teams:tenant-1:personal:conversation-1", + "is_dm": true + }, + "recipient": null, + "mention_entities": [] + }) + .to_string() + } + + fn trusted_probe_context(probe: Arc) -> GatewayEventContext { + let router = Arc::new(teams_router(vec!["trusted-user".into()])); + let dispatcher = Arc::new(crate::dispatch::Dispatcher::with_idle_timeout( + router.clone(), + 1, + 24_000, + crate::dispatch::BatchGrouping::Thread, + std::time::Duration::from_secs(60), + )); + GatewayEventContext { + adapter: probe, + dispatcher, + router, + allow_bot_messages: false, + trusted_bot_ids: HashSet::new(), + bot_username: None, + stt_config: crate::config::SttConfig::default(), + teams_scope_policy: TeamsScopePolicy::default(), + teams_inbound_attachments: true, + #[cfg(feature = "filestore")] + filestore: None, + } + } + + #[tokio::test] + async fn standalone_proxy_is_disconnected_and_generation_safe() { + let proxy = GatewayAdapterProxy::with_platform_name("teams"); + let channel = ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: None, + }; + + let error = proxy + .send_message(&channel, "before connect") + .await + .unwrap_err(); + let failure = error.downcast_ref::().unwrap(); + assert!(matches!( + &failure.outcome, + WriteOutcome::Rejected { code, .. } if code == "gateway_disconnected" + )); + assert!( + !proxy + .capabilities("teams") + .supports_persistent_conversation_send + ); + + let first = proxy.install(Arc::new(ProxyProbeAdapter { + message_id: "generation-1", + persistent_send: false, + })); + assert_eq!( + proxy + .send_message(&channel, "first") + .await + .unwrap() + .message_id, + "generation-1" + ); + + let second = proxy.install(Arc::new(ProxyProbeAdapter { + message_id: "generation-2", + persistent_send: true, + })); + proxy.clear_generation(first); + assert!( + proxy + .capabilities("teams") + .supports_persistent_conversation_send + ); + assert_eq!( + proxy + .send_message(&channel, "second") + .await + .unwrap() + .message_id, + "generation-2" + ); + + proxy.clear_generation(second); + let error = proxy + .send_message(&channel, "after disconnect") + .await + .unwrap_err(); + assert!(matches!( + &error.downcast_ref::().unwrap().outcome, + WriteOutcome::Rejected { code, .. } if code == "gateway_disconnected" + )); + } + + #[tokio::test] + async fn recognized_command_precedes_attachment_materialization() -> anyhow::Result<()> { + let probe = Arc::new(AttachmentProbeAdapter::default()); + let context = trusted_probe_context(probe.clone()); + let mut event: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + event["content"]["text"] = "/cancel".into(); + + assert!(!process_gateway_event(&event.to_string(), &context).await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 1); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + assert_eq!(probe.operations().as_slice(), ["register", "send"]); + assert!(probe.messages()[0].contains("Nothing to cancel")); + Ok(()) + } + + #[tokio::test] + async fn registration_failure_does_not_block_a_trusted_command() -> anyhow::Result<()> { + let probe = Arc::new(AttachmentProbeAdapter::default()); + probe.registration_fails.store(true, Ordering::SeqCst); + let context = trusted_probe_context(probe.clone()); + let mut event: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + event["content"]["text"] = "/cancel".into(); + + assert!(!process_gateway_event(&event.to_string(), &context).await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 1); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + assert_eq!(probe.operations().as_slice(), ["register", "send"]); + Ok(()) + } + + #[tokio::test] + async fn teams_usage_requires_typed_personal_privacy_proof() -> anyhow::Result<()> { + let personal_probe = Arc::new(AttachmentProbeAdapter::default()); + let personal_context = trusted_probe_context(personal_probe.clone()); + let mut personal: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + personal["content"]["text"] = "/usage".into(); + personal["content"]["attachments"] = serde_json::json!([]); + assert!(!process_gateway_event(&personal.to_string(), &personal_context).await?); + assert_eq!(personal_probe.registrations.load(Ordering::SeqCst), 1); + assert!(personal_probe.messages()[0].contains("No active session")); + + let legacy_probe = Arc::new(AttachmentProbeAdapter::default()); + let legacy_context = trusted_probe_context(legacy_probe.clone()); + let mut legacy = personal; + legacy["channel"]["id"] = "legacy-conversation".into(); + legacy["scope"] = serde_json::Value::Null; + assert!(!process_gateway_event(&legacy.to_string(), &legacy_context).await?); + assert_eq!(legacy_probe.registrations.load(Ordering::SeqCst), 0); + assert!(legacy_probe.messages()[0].contains("only available in a private chat")); + Ok(()) + } + + #[tokio::test] + async fn authenticated_teams_mention_enables_command_before_attachment() -> anyhow::Result<()> { + let probe = Arc::new(AttachmentProbeAdapter::default()); + let context = trusted_probe_context(probe.clone()); + let mut event: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + event["channel"]["type"] = "groupChat".into(); + event["scope"]["conversation_type"] = "groupChat".into(); + event["scope"]["trust_scope_id"] = "teams:tenant-1:groupChat:conversation-1".into(); + event["scope"]["is_dm"] = false.into(); + event["recipient"] = serde_json::json!({"id": "bot-id", "name": "OpenAB"}); + event["mentions"] = serde_json::json!(["bot-id"]); + event["mention_entities"] = + serde_json::json!([{"id": "bot-id", "text": "OpenAB"}]); + event["content"]["text"] = "OpenAB /cancel".into(); + + assert!(!process_gateway_event(&event.to_string(), &context).await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 1); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + Ok(()) + } + + #[tokio::test] + async fn trusted_mention_only_event_registers_without_agent_dispatch() -> anyhow::Result<()> { + let probe = Arc::new(AttachmentProbeAdapter::default()); + let context = trusted_probe_context(probe.clone()); + let mut event: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + event["channel"]["type"] = "groupChat".into(); + event["scope"]["conversation_type"] = "groupChat".into(); + event["scope"]["trust_scope_id"] = "teams:tenant-1:groupChat:conversation-1".into(); + event["scope"]["is_dm"] = false.into(); + event["recipient"] = serde_json::json!({"id": "bot-id", "name": "OpenAB"}); + event["mentions"] = serde_json::json!(["bot-id"]); + event["mention_entities"] = + serde_json::json!([{"id": "bot-id", "text": "OpenAB"}]); + event["content"]["text"] = "OpenAB".into(); + event["content"]["attachments"] = serde_json::json!([]); + + assert!(!process_gateway_event(&event.to_string(), &context).await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 1); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 0); + assert_eq!(probe.operations().as_slice(), ["register"]); + Ok(()) + } + + #[tokio::test] + async fn structural_and_typed_scope_denials_never_register() -> anyhow::Result<()> { + let structural_probe = Arc::new(AttachmentProbeAdapter::default()); + let structural_context = trusted_probe_context(structural_probe.clone()); + let mut group: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + group["channel"]["type"] = "groupChat".into(); + group["scope"]["conversation_type"] = "groupChat".into(); + group["scope"]["trust_scope_id"] = "teams:tenant-1:groupChat:conversation-1".into(); + group["scope"]["is_dm"] = false.into(); + assert!(!process_gateway_event(&group.to_string(), &structural_context).await?); + assert_eq!(structural_probe.registrations.load(Ordering::SeqCst), 0); + + let malformed_probe = Arc::new(AttachmentProbeAdapter::default()); + let malformed_context = trusted_probe_context(malformed_probe.clone()); + let mut malformed: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + malformed["scope"]["conversation_type"] = "groupChat".into(); + malformed["scope"]["is_dm"] = false.into(); + assert!(!process_gateway_event(&malformed.to_string(), &malformed_context).await?); + assert_eq!(malformed_probe.registrations.load(Ordering::SeqCst), 0); + + let l2_probe = Arc::new(AttachmentProbeAdapter::default()); + let mut l2_context = trusted_probe_context(l2_probe.clone()); + l2_context.teams_scope_policy = TeamsScopePolicy::new( + true, + Vec::::new(), + Vec::::new(), + false, + true, + true, + Vec::::new(), + ); + assert!( + !process_gateway_event(&attachment_event_json("trusted-user"), &l2_context,).await? + ); + assert_eq!(l2_probe.registrations.load(Ordering::SeqCst), 0); + Ok(()) + } + + #[tokio::test] + async fn command_delivery_attempts_each_terminal_outcome_once() { + for outcome in [ + WriteOutcome::Delivered { + message_id: Some("message".into()), + }, + WriteOutcome::Rejected { + code: "rejected".into(), + message: "rejected".into(), + retry_after_ms: None, + }, + WriteOutcome::Unknown { + code: "timeout".into(), + message: "unknown".into(), + }, + ] { + let probe = Arc::new(OutcomeProbeAdapter { + sends: AtomicUsize::new(0), + outcome, + }); + let context = trusted_probe_context(probe.clone()); + let service = CommandService::new(context.router.pool().clone(), context.dispatcher); + execute_gateway_command( + Command::InvalidArguments { + name: CommandName::Reset, + }, + CommandContext::new("teams", "conversation-1", true), + service, + probe.clone(), + ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: Some("event".into()), + }, + ) + .await; + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn standalone_command_task_does_not_block_ack_dispatch() { + let probe = Arc::new(BlockingOutcomeAdapter::default()); + let context = trusted_probe_context(probe.clone()); + let service = CommandService::new(context.router.pool().clone(), context.dispatcher); + let mut tasks = tokio::task::JoinSet::new(); + spawn_gateway_command( + &mut tasks, + Command::InvalidArguments { + name: CommandName::Cancel, + }, + CommandContext::new("teams", "conversation-1", true), + service, + probe.clone(), + ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: Some("event".into()), + }, + ); + + let started = tokio::time::timeout( + std::time::Duration::from_millis(100), + probe.started.acquire(), + ) + .await + .expect("spawned command reached delivery") + .expect("semaphore open"); + started.forget(); + assert_eq!(tasks.len(), 1); + probe.release.add_permits(1); + tokio::time::timeout(std::time::Duration::from_millis(100), tasks.join_next()) + .await + .expect("command task completed") + .expect("command task present") + .expect("command task succeeded"); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn standalone_teams_task_orders_registration_before_command() -> anyhow::Result<()> { + let probe = Arc::new(BlockingOutcomeAdapter::default()); + let context = Arc::new(trusted_probe_context(probe.clone())); + let mut event: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + event["content"]["text"] = "/cancel".into(); + event["content"]["attachments"] = serde_json::json!([]); + let mut tasks = tokio::task::JoinSet::new(); + spawn_teams_gateway_event( + &mut tasks, + event.to_string(), + context, + Arc::new(Mutex::new(())), + false, + ); + + let registration_started = tokio::time::timeout( + std::time::Duration::from_millis(100), + probe.registration_started.acquire(), + ) + .await + .expect("detached Teams task reached registration") + .expect("semaphore open"); + registration_started.forget(); + assert_eq!(probe.sends.load(Ordering::SeqCst), 0); + assert!(tokio::time::timeout( + std::time::Duration::from_millis(20), + probe.started.acquire(), + ) + .await + .is_err()); + + probe.registration_release.add_permits(1); + let command_started = tokio::time::timeout( + std::time::Duration::from_millis(100), + probe.started.acquire(), + ) + .await + .expect("command started after registration") + .expect("semaphore open"); + command_started.forget(); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + probe.release.add_permits(1); + tokio::time::timeout(std::time::Duration::from_millis(100), tasks.join_next()) + .await + .expect("Teams event task completed") + .expect("Teams event task present") + .expect("Teams event task succeeded"); + Ok(()) + } + + #[tokio::test] + async fn identity_denial_precedes_attachment_materialization() -> anyhow::Result<()> { + let pool = Arc::new(crate::acp::SessionPool::new( + crate::config::AgentConfig::default(), + 1, + 900, + HashMap::new(), + )); + let router = Arc::new(AdapterRouter::new( + pool, + crate::config::ReactionsConfig::default(), + crate::markdown::TableMode::default(), + 900, + 30, + HashMap::new(), + std::env::temp_dir(), + )); + let dispatcher = Arc::new(crate::dispatch::Dispatcher::with_idle_timeout( + router.clone(), + 1, + 24_000, + crate::dispatch::BatchGrouping::Thread, + std::time::Duration::from_secs(1), + )); + let probe = Arc::new(AttachmentProbeAdapter::default()); + let adapter: Arc = probe.clone(); + let context = GatewayEventContext { + adapter, + dispatcher, + router, + allow_bot_messages: false, + trusted_bot_ids: HashSet::new(), + bot_username: None, + stt_config: crate::config::SttConfig::default(), + teams_scope_policy: TeamsScopePolicy::default(), + teams_inbound_attachments: true, + #[cfg(feature = "filestore")] + filestore: None, + }; + let event_json = attachment_event_json("untrusted-user"); + assert!(!process_gateway_event(&event_json, &context).await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 0); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + Ok(()) + } + + #[tokio::test] + async fn admitted_attachment_is_materialized_before_dispatch() -> anyhow::Result<()> { + let router = Arc::new(teams_router(vec!["trusted-user".into()])); + let dispatcher = Arc::new(crate::dispatch::Dispatcher::with_idle_timeout( + router.clone(), + 1, + 24_000, + crate::dispatch::BatchGrouping::Thread, + std::time::Duration::from_secs(60), + )); + let probe = Arc::new(AttachmentProbeAdapter::default()); + let mut context = GatewayEventContext { + adapter: probe.clone(), + dispatcher, + router, + allow_bot_messages: false, + trusted_bot_ids: HashSet::new(), + bot_username: None, + stt_config: crate::config::SttConfig::default(), + teams_scope_policy: TeamsScopePolicy::default(), + teams_inbound_attachments: true, + #[cfg(feature = "filestore")] + filestore: None, + }; + + assert!(process_gateway_event( + &attachment_event_json("trusted-user"), + &context, + ) + .await?); + assert_eq!(probe.registrations.load(Ordering::SeqCst), 1); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 1); + assert_eq!(probe.sends.load(Ordering::SeqCst), 0); + assert_eq!(probe.operations().as_slice(), ["register", "materialize"]); + + let mut injected: serde_json::Value = + serde_json::from_str(&attachment_event_json("trusted-user"))?; + injected["event_id"] = "event-pre-materialized".into(); + injected["content"]["attachments"][0]["reference"] = serde_json::Value::Null; + injected["content"]["attachments"][0]["data"] = "c2VjcmV0".into(); + assert!(!process_gateway_event(&injected.to_string(), &context).await?); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 1); + + context.teams_inbound_attachments = false; + assert!(!process_gateway_event( + &attachment_event_json("trusted-user"), + &context, + ) + .await?); + assert_eq!(probe.materializations.load(Ordering::SeqCst), 1); + Ok(()) + } + + #[test] + fn legacy_non_editable_platforms_are_send_once() { + for platform in ["line", "lineworks", "acp"] { + let capabilities = legacy_gateway_capabilities(platform, true, true); + assert!(!capabilities.can_edit, "{platform} must not advertise edit"); + assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); + } + } + + #[test] + fn legacy_editable_platforms_preserve_configured_streaming() { + for platform in [ + "telegram", + "slack", + "discord", + "feishu", + "googlechat", + "wecom", + ] { + let capabilities = legacy_gateway_capabilities(platform, true, true); + assert!(capabilities.can_edit, "{platform} should advertise edit"); + assert_eq!(capabilities.streaming_mode, StreamingMode::Edit); + } + let feishu = legacy_gateway_capabilities("feishu", true, true); + assert!(feishu.edit_ack); + assert!(!feishu.send_ack, "legacy peers never require send ACK"); + } + + #[test] + fn teams_processing_message_requires_all_negotiated_write_primitives() { + let supported = AdapterCapabilities { + send_ack: true, + edit_ack: true, + delete_ack: true, + supports_target_message_id: true, + supports_reactions: true, + can_edit: true, + can_delete: true, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }; + + let mut legacy_reactions = AdapterCapabilities { + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }; + normalize_reaction_support(&mut legacy_reactions); + assert!(legacy_reactions.supports_reactions); + + let mut before_hello = supported.clone(); + apply_teams_processing_indicator(false, true, &mut before_hello); + assert_eq!(before_hello.status_backend, StatusBackend::None); + assert!(before_hello.supports_reactions); + + let mut disabled = supported.clone(); + apply_teams_processing_indicator(true, false, &mut disabled); + assert_eq!(disabled.status_backend, StatusBackend::Reactions); + + for missing in 0..6 { + let mut capabilities = supported.clone(); + match missing { + 0 => capabilities.send_ack = false, + 1 => capabilities.edit_ack = false, + 2 => capabilities.delete_ack = false, + 3 => capabilities.supports_target_message_id = false, + 4 => capabilities.can_edit = false, + 5 => capabilities.can_delete = false, + _ => unreachable!(), + } + apply_teams_processing_indicator(true, true, &mut capabilities); + assert_eq!(capabilities.status_backend, StatusBackend::None); } - } - // Slash command interception - let prompt = event.content.text.clone(); - let trimmed = prompt.trim(); - if trimmed == "/reset" { - let thread_id_str = event.channel.thread_id.as_deref().unwrap_or(&event.channel.id); - let thread_key = format!("{}:{}", event.platform, thread_id_str); - let dropped = ctx.dispatcher.cancel_buffered_thread(event.platform.as_str(), thread_id_str); - let msg = match (ctx.router.pool().reset_session(&thread_key).await, dropped) { - (Ok(()), 0) => "🔄 Session reset. Start a new conversation!".to_string(), - (Ok(()), n) => format!("🔄 Session reset. Dropped {n} buffered message(s). Start a new conversation!"), - (Err(_), 0) => "⚠️ No active session to reset.".to_string(), - (Err(_), n) => format!("🔄 Dropped {n} buffered message(s). No active session to reset."), - }; - let _ = ctx.adapter.send_message(&channel, &msg).await; - return Ok(false); + let mut capabilities = supported; + apply_teams_processing_indicator(true, true, &mut capabilities); + assert_eq!(capabilities.status_backend, StatusBackend::Message); + assert!(capabilities.supports_reactions); } - if trimmed == "/cancel" { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); - let msg = match ctx.router.pool().cancel_session(&thread_key).await { - Ok(()) => "🛑 Cancel signal sent.".to_string(), - Err(e) => format!("⚠️ {e}"), + + #[test] + fn teams_progressive_response_requires_all_negotiated_write_primitives() { + let supported = AdapterCapabilities { + send_ack: true, + edit_ack: true, + delete_ack: true, + supports_target_message_id: true, + can_edit: true, + can_delete: true, + show_streaming_placeholder: true, + ..AdapterCapabilities::default() }; - let _ = ctx.adapter.send_message(&channel, &msg).await; - return Ok(false); - } - { - let thread_key = format!("{}:{}", event.platform, event.channel.thread_id.as_deref().unwrap_or(&event.channel.id)); - if let Some(msg) = handle_config_command(trimmed, &ctx.router, &thread_key).await { - let _ = ctx.adapter.send_message(&channel, &msg).await; - return Ok(false); + + let mut before_hello = supported.clone(); + before_hello.streaming_mode = StreamingMode::Edit; + apply_teams_progressive_capabilities(false, true, &mut before_hello); + assert_eq!(before_hello.streaming_mode, StreamingMode::Disabled); + + let mut disabled = supported.clone(); + apply_teams_progressive_capabilities(true, false, &mut disabled); + assert_eq!(disabled.streaming_mode, StreamingMode::Disabled); + + for missing in 0..7 { + let mut capabilities = supported.clone(); + match missing { + 0 => capabilities.send_ack = false, + 1 => capabilities.edit_ack = false, + 2 => capabilities.delete_ack = false, + 3 => capabilities.supports_target_message_id = false, + 4 => capabilities.can_edit = false, + 5 => capabilities.can_delete = false, + 6 => capabilities.show_streaming_placeholder = false, + _ => unreachable!(), + } + apply_teams_progressive_capabilities(true, true, &mut capabilities); + assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); } + + let mut capabilities = supported; + apply_teams_progressive_capabilities(true, true, &mut capabilities); + assert_eq!(capabilities.streaming_mode, StreamingMode::Edit); + assert!(capabilities.show_streaming_placeholder); } - // Submit to dispatcher - let adapter = ctx.adapter.clone(); - let dispatcher = ctx.dispatcher.clone(); - let sender_name = event.sender.name.clone(); - let sender_id = event.sender.id.clone(); + #[test] + fn capability_state_uses_legacy_only_before_successful_negotiation() { + let state = GatewayCapabilityState::default(); + let legacy = legacy_gateway_capabilities("telegram", true, true); + let (negotiated, resolved) = state.resolve("telegram", &legacy); + assert!(!negotiated); + assert_eq!(resolved, legacy); - tokio::spawn(async move { - let thread_channel = if event.channel.channel_type == "supergroup" - && channel.thread_id.is_none() - { - let title = crate::format::shorten_thread_name(&prompt); - match adapter.create_thread(&channel, &trigger_msg, &title).await { - Ok(tc) => tc, - Err(e) => { - tracing::warn!("create_thread failed, replying in channel: {e}"); - channel.clone() - } - } - } else { - channel.clone() + let advertised = AdapterCapabilities { + send_ack: true, + can_edit: true, + streaming_mode: StreamingMode::Edit, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() }; + state.update(GatewayHello { + schema: GATEWAY_HELLO_SCHEMA.into(), + protocol_version: GATEWAY_PROTOCOL_VERSION, + capabilities: HashMap::from([("telegram".into(), advertised.clone())]), + topology: GatewayTopology { + active_consumers: 1, + supported: true, + delivery_mode: "best_effort_broadcast".into(), + }, + }); - let thread_id = thread_channel - .thread_id - .as_deref() - .unwrap_or(&thread_channel.channel_id); - let thread_key = dispatcher.key( - &thread_channel.platform, - thread_id, - &sender_id, + let (negotiated, resolved) = state.resolve("telegram", &legacy); + assert!(negotiated); + assert_eq!(resolved, advertised); + + // Once a hello was accepted, an omitted platform is not allowed to + // inherit optimistic legacy behavior. + let (_, missing) = state.resolve("unadvertised", &legacy); + assert_eq!(missing, AdapterCapabilities::default()); + assert_eq!(missing.status_backend, StatusBackend::None); + } + + #[test] + fn teams_message_budget_preserves_legacy_and_valid_hello_boundaries() { + let legacy = legacy_gateway_capabilities("teams", true, true); + let before_hello = GatewayCapabilityState::default(); + let (negotiated, resolved) = before_hello.resolve("teams", &legacy); + assert!(!negotiated); + assert_eq!( + resolved.message_limit, + MessageLimit::Characters { max: 4096 } ); - let estimated_tokens = - crate::dispatch::estimate_tokens(&prompt, &extra_blocks); - let buf_msg = crate::dispatch::BufferedMessage { - sender_json, - sender_name, - prompt, - extra_blocks, - trigger_msg, - arrived_at: std::time::Instant::now(), - estimated_tokens, - other_bot_present: false, - recipient: None, + + let advertised = AdapterCapabilities { + send_ack: true, + message_limit: MessageLimit::Utf16Bytes { max: 80_000 }, + ..AdapterCapabilities::default() }; - if let Err(e) = dispatcher - .submit(thread_key, thread_channel, adapter, buf_msg) - .await - { - tracing::error!("gateway dispatcher submit error: {e}"); - } - }); + before_hello.update(GatewayHello { + schema: GATEWAY_HELLO_SCHEMA.into(), + protocol_version: GATEWAY_PROTOCOL_VERSION, + capabilities: HashMap::from([("teams".into(), advertised.clone())]), + topology: GatewayTopology { + active_consumers: 1, + supported: true, + delivery_mode: "best_effort_broadcast".into(), + }, + }); + let (negotiated, resolved) = before_hello.resolve("teams", &legacy); + assert!(negotiated); + assert_eq!(resolved, advertised); + assert_eq!(resolved.message_limit.conservative_char_limit(), 20_000); - Ok(true) -} + let valid_hello_without_teams = GatewayCapabilityState::default(); + valid_hello_without_teams.update(GatewayHello { + schema: GATEWAY_HELLO_SCHEMA.into(), + protocol_version: GATEWAY_PROTOCOL_VERSION, + capabilities: HashMap::new(), + topology: GatewayTopology { + active_consumers: 1, + supported: true, + delivery_mode: "best_effort_broadcast".into(), + }, + }); + let (negotiated, missing) = valid_hello_without_teams.resolve("teams", &legacy); + assert!(negotiated); + assert!(!missing.send_ack); + assert_eq!( + missing.message_limit, + MessageLimit::Characters { max: 4096 } + ); + } -fn format_size(n: u64) -> String { - if n >= 1024 * 1024 { - format!("{:.1} MB", n as f64 / (1024.0 * 1024.0)) - } else if n >= 1024 { - format!("{:.1} KB", n as f64 / 1024.0) - } else { - format!("{} B", n) + #[test] + fn legacy_and_structured_gateway_responses_map_to_write_outcomes() { + let Ok(legacy): Result = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.response.v1", + "request_id": "req-legacy", + "success": true, + "thread_id": null, + "message_id": "activity-1", + "error": null + })) else { + panic!("legacy response fixture must decode"); + }; + assert_eq!( + legacy.write_outcome(), + WriteOutcome::Delivered { + message_id: Some("activity-1".into()) + } + ); + + let Ok(unknown): Result = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.response.v1", + "request_id": "req-new", + "success": false, + "thread_id": null, + "message_id": null, + "error": "delivery may have completed", + "outcome": "unknown", + "error_code": "request_timeout" + })) else { + panic!("structured response fixture must decode"); + }; + assert_eq!( + unknown.write_outcome(), + WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "delivery may have completed".into() + } + ); } -} -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; + #[test] + fn command_target_field_is_negotiated_with_legacy_fallback() { + let message = MessageRef { + channel: ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: Some("event-1".into()), + }, + message_id: "activity-1".into(), + }; + let supported = AdapterCapabilities { + supports_target_message_id: true, + ..AdapterCapabilities::default() + }; + + assert_eq!( + command_target_fields(&message, true, &supported), + ("event-1".into(), Some("activity-1".into())) + ); + assert_eq!( + command_target_fields(&message, false, &supported), + ("activity-1".into(), None) + ); + assert_eq!( + command_target_fields(&message, true, &AdapterCapabilities::default()), + ("activity-1".into(), None) + ); + } #[test] - fn line_cannot_stream_and_is_forced_send_once() { - // LINE has no message-edit API, so cosmetic streaming is impossible. - assert!(!platform_supports_streaming("line")); + fn old_gateway_hello_defaults_conversation_registry_fail_closed() { + let hello: GatewayHello = serde_json::from_value(serde_json::json!({ + "schema": GATEWAY_HELLO_SCHEMA, + "protocol_version": GATEWAY_PROTOCOL_VERSION, + "capabilities": { + "teams": { "send_ack": true } + }, + "topology": { + "active_consumers": 1, + "supported": true, + "delivery_mode": "best_effort_broadcast" + } + })) + .expect("old hello should remain decodable"); + let teams = hello.capabilities.get("teams").expect("Teams capability"); + assert!(!teams.supports_conversation_registry); + assert!(!teams.supports_persistent_conversation_send); } #[test] - fn editable_platforms_still_allow_streaming() { - for platform in [ - "telegram", - "slack", - "discord", - "feishu", - "teams", - "googlechat", - "wecom", - ] { - assert!( - platform_supports_streaming(platform), - "{platform} should still support streaming", - ); - } + fn client_hello_wire_shape_is_additive_and_versioned() { + let Ok(value) = serde_json::to_value(build_client_hello()) else { + panic!("client hello fixture must encode"); + }; + assert_eq!(value["schema"], CLIENT_HELLO_SCHEMA); + assert_eq!(value["protocol_version"], GATEWAY_PROTOCOL_VERSION); + assert!(value["client_name"] + .as_str() + .is_some_and(|name| name.starts_with("openab-core/"))); + assert_eq!(value["requested_platforms"], serde_json::json!([])); } #[test] @@ -1754,7 +4134,7 @@ mod tests { } fn make_event(is_bot: bool, sender_id: &str, channel_id: &str, channel_type: &str, thread_id: Option<&str>, mentions: Vec<&str>) -> GatewayEvent { - serde_json::from_value(serde_json::json!({ + match serde_json::from_value(serde_json::json!({ "schema": "openab.gateway.event.v1", "event_id": "evt1", "timestamp": "", @@ -1764,7 +4144,69 @@ mod tests { "content": { "type": "text", "text": "hello" }, "mentions": mentions, "message_id": "msg1" - })).unwrap() + })) { + Ok(event) => event, + Err(_) => panic!("gateway event fixture must decode"), + } + } + + fn make_teams_event(conversation_type: &str, is_dm: bool, mentions: Vec<&str>) -> GatewayEvent { + let mut event = make_event( + false, + "29:user", + "conversation-1", + conversation_type, + None, + mentions, + ); + event.platform = "teams".into(); + event.scope = Some(GwScope { + tenant_id: Some("tenant-1".into()), + team_id: Some("team-1".into()), + channel_id: Some("channel-1".into()), + conversation_type: conversation_type.into(), + trust_scope_id: format!("teams:tenant-1:{conversation_type}:conversation-1"), + is_dm, + }); + event.recipient = Some(GwRecipient { + id: "28:bot".into(), + name: "OpenAB".into(), + }); + event + } + + fn teams_scope(event: &GatewayEvent) -> &GwScope { + event.scope.as_ref().expect("Teams test event scope") + } + + fn teams_router(allowed_users: Vec) -> crate::adapter::AdapterRouter { + let pool = Arc::new(crate::acp::SessionPool::new( + crate::config::AgentConfig::default(), + 1, + 1, + HashMap::new(), + )); + let mut trust = crate::trust::PlatformTrustConfigs::new(); + trust.insert( + "teams", + crate::trust::TrustConfig::new( + Some(false), + ["legacy-conversation".into()], + Some(false), + Some(false), + allowed_users, + ), + ); + crate::adapter::AdapterRouter::new( + pool, + crate::config::ReactionsConfig::default(), + crate::markdown::TableMode::Code, + 60, + 1, + HashMap::new(), + std::env::temp_dir(), + ) + .with_trust(trust) } fn default_filter<'a>(allowed_channels: &'a HashSet, allowed_users: &'a HashSet, trusted_bot_ids: &'a HashSet) -> EventFilterParams<'a> { @@ -1875,6 +4317,396 @@ mod tests { let event = make_event(false, "u1", "ch1", "group", Some("thread1"), vec![]); assert!(!should_skip_event(&event, &filter)); } + + #[test] + fn teams_trigger_matrix_uses_recipient_entity_ids() { + let ch = HashSet::new(); + let us = HashSet::new(); + let tb = HashSet::new(); + let filter = default_filter(&ch, &us, &tb); + + let personal = make_teams_event("personal", true, vec![]); + assert!(!should_skip_event(&personal, &filter)); + + let mut unmentioned_group = make_teams_event("groupChat", false, vec![]); + unmentioned_group.content.text = "@OpenAB OpenAB spoof".into(); + assert!(should_skip_event(&unmentioned_group, &filter)); + let mentioned_group = make_teams_event("groupChat", false, vec!["28:bot"]); + assert!(!should_skip_event(&mentioned_group, &filter)); + let multi_mention = make_teams_event("groupChat", false, vec!["29:other", "28:bot"]); + assert!(!should_skip_event(&multi_mention, &filter)); + let other_mention = make_teams_event("groupChat", false, vec!["29:other"]); + assert!(should_skip_event(&other_mention, &filter)); + let mut missing_recipient = make_teams_event("groupChat", false, vec!["28:bot"]); + missing_recipient.recipient = None; + assert!(should_skip_event(&missing_recipient, &filter)); + + let mut threaded_channel = make_teams_event("channel", false, vec![]); + threaded_channel.channel.thread_id = Some("reply-chain".into()); + assert!( + should_skip_event(&threaded_channel, &filter), + "Teams thread presence must not bypass structured mention gating" + ); + threaded_channel.mentions.push("28:bot".into()); + assert!( + !should_skip_event(&threaded_channel, &filter), + "a structured recipient mention must trigger in a channel reply" + ); + + let malformed_personal = make_teams_event("personal", false, vec![]); + assert!(should_skip_event(&malformed_personal, &filter)); + let unknown = make_teams_event("meeting", false, vec!["28:bot"]); + assert!(should_skip_event(&unknown, &filter)); + } + + #[test] + fn teams_recipient_mention_cleanup_preserves_other_mentions() { + let mut non_teams = make_event(false, "u1", "channel-1", "group", None, vec![]); + non_teams.content.text = " unchanged ".into(); + assert_eq!(strip_recipient_mention(&non_teams), " unchanged "); + + let mut event = make_teams_event("channel", false, vec!["29:other", "28:bot"]); + event.content.text = "Same ask Same now".into(); + event.mention_entities = vec![ + GwMention { + id: "29:other".into(), + text: "Same".into(), + }, + GwMention { + id: "28:bot".into(), + text: "Same".into(), + }, + ]; + assert_eq!(strip_recipient_mention(&event), "Same ask now"); + + event.content.text = "OpenAB /reset".into(); + event.mention_entities = vec![GwMention { + id: "28:bot".into(), + text: "OpenAB".into(), + }]; + assert_eq!(strip_recipient_mention(&event), "/reset"); + event.content.text = " OpenAB ".into(); + assert!(strip_recipient_mention(&event).is_empty()); + + event.content.text = "OpenAB spoof".into(); + event.mention_entities.clear(); + assert_eq!( + strip_recipient_mention(&event), + "OpenAB spoof", + "markup without an entity must remain ordinary text" + ); + + event.mention_entities.push(GwMention { + id: "28:bot".into(), + text: String::new(), + }); + assert_eq!(strip_recipient_mention(&event), "OpenAB spoof"); + + event.content.text = "OpenAB one OpenAB two".into(); + event.mention_entities = vec![ + GwMention { + id: "28:bot".into(), + text: "OpenAB".into(), + }, + GwMention { + id: "28:bot".into(), + text: "OpenAB".into(), + }, + ]; + assert_eq!(strip_recipient_mention(&event), "one two"); + + event.content.text = "text without matching markup".into(); + event.mention_entities = vec![GwMention { + id: "28:bot".into(), + text: "OpenAB".into(), + }]; + assert_eq!( + strip_recipient_mention(&event), + "text without matching markup" + ); + } + + #[test] + fn teams_typed_scope_policy_is_kind_aware_and_legacy_compatible() { + let typed = TeamsScopePolicy::new( + true, + ["team-1".into()], + ["channel-2".into()], + true, + false, + false, + ["legacy-conversation".into()], + ); + let personal = make_teams_event("personal", true, vec![]); + assert!(typed.surface_allowed( + &personal.channel.id, + &personal.channel.channel_type, + teams_scope(&personal) + )); + let group = make_teams_event("groupChat", false, vec!["28:bot"]); + assert!(!typed.surface_allowed( + &group.channel.id, + &group.channel.channel_type, + teams_scope(&group) + )); + let channel = make_teams_event("channel", false, vec!["28:bot"]); + assert!(typed.surface_allowed( + &channel.channel.id, + &channel.channel.channel_type, + teams_scope(&channel) + )); + + let mut channel_match = channel.clone(); + let scope = channel_match + .scope + .as_mut() + .expect("Teams test event scope"); + scope.team_id = Some("other-team".into()); + scope.channel_id = Some("channel-2".into()); + assert!(typed.surface_allowed( + &channel_match.channel.id, + &channel_match.channel.channel_type, + scope + )); + scope.channel_id = None; + assert!(!typed.surface_allowed( + &channel_match.channel.id, + &channel_match.channel.channel_type, + scope + )); + + let typed_open = TeamsScopePolicy::new( + true, + Vec::::new(), + Vec::::new(), + false, + true, + false, + Vec::::new(), + ); + assert!(typed_open.surface_allowed( + &channel.channel.id, + &channel.channel.channel_type, + teams_scope(&channel) + )); + assert!(!typed_open.surface_allowed( + &personal.channel.id, + &personal.channel.channel_type, + teams_scope(&personal) + )); + + let legacy = TeamsScopePolicy::new( + false, + Vec::::new(), + Vec::::new(), + true, + true, + false, + ["conversation-1".into()], + ); + assert!(legacy.surface_allowed( + &channel.channel.id, + &channel.channel.channel_type, + teams_scope(&channel) + )); + assert!(!legacy.surface_allowed( + "other-conversation", + &channel.channel.channel_type, + teams_scope(&channel) + )); + } + + #[test] + fn teams_scope_shape_validation_fails_closed() { + let personal = make_teams_event("personal", true, vec![]); + assert!(typed_scope_shape_is_valid( + &personal.channel.id, + &personal.channel.channel_type, + teams_scope(&personal) + )); + + let mut malformed = personal.clone(); + malformed.scope.as_mut().expect("scope").tenant_id = None; + assert!(!typed_scope_shape_is_valid( + &malformed.channel.id, + &malformed.channel.channel_type, + teams_scope(&malformed) + )); + + let mut malformed = personal.clone(); + malformed.scope.as_mut().expect("scope").trust_scope_id = " ".into(); + assert!(!typed_scope_shape_is_valid( + &malformed.channel.id, + &malformed.channel.channel_type, + teams_scope(&malformed) + )); + + let mut malformed = personal.clone(); + malformed.scope.as_mut().expect("scope").is_dm = false; + assert!(!typed_scope_shape_is_valid( + &malformed.channel.id, + &malformed.channel.channel_type, + teams_scope(&malformed) + )); + + let mut malformed = personal.clone(); + malformed.channel.id.clear(); + assert!(!typed_scope_shape_is_valid( + &malformed.channel.id, + &malformed.channel.channel_type, + teams_scope(&malformed) + )); + + let channel = make_teams_event("channel", false, vec!["28:bot"]); + assert!(typed_scope_shape_is_valid( + &channel.channel.id, + &channel.channel.channel_type, + teams_scope(&channel) + )); + let mut missing_team = channel.clone(); + missing_team.scope.as_mut().expect("scope").team_id = None; + assert!(!typed_scope_shape_is_valid( + &missing_team.channel.id, + &missing_team.channel.channel_type, + teams_scope(&missing_team) + )); + let mut missing_channel = channel.clone(); + missing_channel.scope.as_mut().expect("scope").channel_id = None; + assert!(!typed_scope_shape_is_valid( + &missing_channel.channel.id, + &missing_channel.channel.channel_type, + teams_scope(&missing_channel) + )); + let mut mismatched_type = channel.clone(); + mismatched_type + .scope + .as_mut() + .expect("scope") + .conversation_type = "groupChat".into(); + assert!(!typed_scope_shape_is_valid( + &mismatched_type.channel.id, + &mismatched_type.channel.channel_type, + teams_scope(&mismatched_type) + )); + + let unknown = make_teams_event("meeting", false, vec!["28:bot"]); + assert!(!typed_scope_shape_is_valid( + &unknown.channel.id, + &unknown.channel.channel_type, + teams_scope(&unknown) + )); + } + + #[test] + fn teams_gate_orders_typed_scope_before_l3_and_keeps_legacy_fallback() { + let router = teams_router(vec!["29:user".into()]); + let typed = TeamsScopePolicy::new( + true, + ["team-1".into()], + Vec::::new(), + true, + true, + false, + ["legacy-conversation".into()], + ); + let channel = make_teams_event("channel", false, vec!["28:bot"]); + assert!(matches!( + gate_gateway_event(&router, &channel, &typed), + GateOutcome::Allow + )); + + let mut untrusted = channel.clone(); + untrusted.sender.id = "29:untrusted".into(); + assert!(matches!( + gate_gateway_event(&router, &untrusted, &typed), + GateOutcome::Deny { echo: Some(_) } + )); + + let mut malformed = untrusted; + malformed.scope.as_mut().expect("scope").team_id = None; + assert!(matches!( + gate_gateway_event(&router, &malformed, &typed), + GateOutcome::Deny { echo: None } + )); + + let legacy = TeamsScopePolicy::new( + false, + Vec::::new(), + Vec::::new(), + true, + true, + false, + ["legacy-conversation".into()], + ); + let mut old_event = make_teams_event("channel", false, vec![]); + old_event.scope = None; + old_event.channel.id = "legacy-conversation".into(); + assert!(matches!( + gate_gateway_event(&router, &old_event, &legacy), + GateOutcome::Allow + )); + old_event.channel.id = "other-conversation".into(); + assert!(matches!( + gate_gateway_event(&router, &old_event, &legacy), + GateOutcome::Deny { echo: None } + )); + } + + #[test] + fn gateway_event_typed_teams_fields_decode_additively() { + let legacy = make_event(false, "u1", "conversation-1", "groupChat", None, vec![]); + assert!(legacy.scope.is_none()); + assert!(legacy.recipient.is_none()); + assert!(legacy.mention_entities.is_empty()); + + let modern: GatewayEvent = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.event.v1", + "event_id": "evt1", + "timestamp": "2024-01-01T00:00:00Z", + "platform": "teams", + "bot_id": "28:bot", + "sender": { + "id": "29:user", + "name": "user", + "display_name": "User", + "is_bot": false + }, + "channel": { "id": "conversation-1", "type": "channel" }, + "content": { + "type": "text", + "text": "OpenAB hello", + "attachments": [{ + "type": "image", + "filename": "image.png", + "mime_type": "image/png", + "reference": "att-opaque", + "size": 0 + }] + }, + "mentions": ["28:bot"], + "message_id": "msg1", + "scope": { + "tenant_id": "tenant-1", + "team_id": "team-1", + "channel_id": "channel-1", + "conversation_type": "channel", + "trust_scope_id": "teams:tenant-1:team:team-1:channel:channel-1", + "is_dm": false + }, + "recipient": { "id": "28:bot", "name": "OpenAB" }, + "mention_entities": [ + { "id": "28:bot", "text": "OpenAB" } + ] + })) + .expect("typed Teams Gateway event should decode"); + + assert_eq!(teams_scope(&modern).team_id.as_deref(), Some("team-1")); + assert_eq!( + modern.recipient.as_ref().map(|r| r.id.as_str()), + Some("28:bot") + ); + assert_eq!(modern.mention_entities.len(), 1); + } } /// Render a channel id for logs, hashing it when it is an ACP channel or session id. diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 0e61e7cb2..81a2f80b3 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod acp_mcp; pub mod redact; pub mod bot_turns; pub mod config; +pub mod commands; pub mod cron; pub mod directives; pub mod dispatch; @@ -20,10 +21,12 @@ pub mod pre_seed; #[cfg(feature = "filestore")] pub mod filestore; pub mod reactions; +mod progressive; #[cfg(feature = "discord")] pub mod remind; pub mod secrets; pub mod setup; +pub mod status; pub mod stt; pub mod timestamp; pub mod trust; diff --git a/crates/openab-core/src/progressive.rs b/crates/openab-core/src/progressive.rs new file mode 100644 index 000000000..dad80f012 --- /dev/null +++ b/crates/openab-core/src/progressive.rs @@ -0,0 +1,1116 @@ +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef, WriteOutcome}; +use std::sync::Arc; + +pub(crate) const COSMETIC_EDIT_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(1500); +const MAX_CONSECUTIVE_EDIT_FAILURES: u32 = 3; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CosmeticEditOutcome { + Delivered, + Rejected, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FinalEditPlan { + Put, + AlreadyDelivered, + RecoverRejected, + Ambiguous, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct CosmeticEditState { + last_attempted: String, + last_outcome: Option, + consecutive_failures: u32, +} + +impl CosmeticEditState { + /// Reserve one changed display value before awaiting its PUT. The + /// provisional outcome is Unknown so task cancellation cannot turn an + /// in-flight write into a duplicate final PUT. + pub fn begin_attempt(&mut self, content: String) -> bool { + if content == self.last_attempted { + return false; + } + self.last_attempted = content; + self.last_outcome = Some(CosmeticEditOutcome::Unknown); + true + } + + /// Complete the reserved PUT. Returns true when cosmetic streaming must + /// stop for this turn. + pub fn complete_attempt(&mut self, outcome: CosmeticEditOutcome) -> bool { + self.last_outcome = Some(outcome); + if outcome == CosmeticEditOutcome::Delivered { + self.consecutive_failures = 0; + } else { + self.consecutive_failures += 1; + } + self.consecutive_failures >= MAX_CONSECUTIVE_EDIT_FAILURES + } + + pub fn consecutive_failures(&self) -> u32 { + self.consecutive_failures + } + + fn final_edit_plan(&self, final_content: &str) -> FinalEditPlan { + if self.last_attempted != final_content { + return FinalEditPlan::Put; + } + match self.last_outcome { + Some(CosmeticEditOutcome::Delivered) => FinalEditPlan::AlreadyDelivered, + Some(CosmeticEditOutcome::Rejected) => FinalEditPlan::RecoverRejected, + Some(CosmeticEditOutcome::Unknown) => FinalEditPlan::Ambiguous, + None => FinalEditPlan::Put, + } + } +} + +#[derive(Debug)] +pub(crate) struct AmbiguousProgressiveDelivery; + +impl std::fmt::Display for AmbiguousProgressiveDelivery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "progressive delivery outcome is ambiguous") + } +} + +impl std::error::Error for AmbiguousProgressiveDelivery {} + +pub(crate) fn is_ambiguous_delivery(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some() +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ChunkFailure { + pub delivered_chunks: usize, + pub total_chunks: usize, + pub failed_chunk_index: usize, + pub error_code: String, +} + +fn sanitize_error_code(error_code: &str) -> String { + let error_code: String = error_code + .chars() + .filter(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-')) + .take(64) + .collect(); + if error_code.is_empty() { + "write_failed".into() + } else { + error_code + } +} + +impl ChunkFailure { + fn new(delivered: usize, total: usize, failed_index: usize, error_code: &str) -> Self { + Self { + delivered_chunks: delivered, + total_chunks: total, + failed_chunk_index: failed_index, + error_code: sanitize_error_code(error_code), + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProgressiveDelivery { + pub failed: bool, + pub ambiguous: bool, + pub chunk_failure: Option, +} + +impl ProgressiveDelivery { + fn rejected_chunk(delivered: usize, total: usize, failed_index: usize, code: &str) -> Self { + Self { + failed: true, + ambiguous: false, + chunk_failure: Some(ChunkFailure::new(delivered, total, failed_index, code)), + } + } + + fn unknown_chunk(delivered: usize, total: usize, failed_index: usize, code: &str) -> Self { + Self { + failed: true, + ambiguous: true, + chunk_failure: Some(ChunkFailure::new(delivered, total, failed_index, code)), + } + } + + fn with_delivered_prefix(mut self, prefix: usize, total: usize) -> Self { + if let Some(failure) = &mut self.chunk_failure { + failure.delivered_chunks = failure.delivered_chunks.saturating_add(prefix); + failure.failed_chunk_index = failure.failed_chunk_index.saturating_add(prefix); + failure.total_chunks = total; + } + self + } +} + +#[derive(Debug)] +pub(crate) enum PlaceholderStart { + Ready(MessageRef), + Rejected, + Unknown, +} + +pub(crate) fn classify_placeholder( + channel: &ChannelRef, + outcome: WriteOutcome, +) -> PlaceholderStart { + match outcome { + WriteOutcome::Delivered { + message_id: Some(message_id), + } if !message_id.is_empty() => PlaceholderStart::Ready(MessageRef { + channel: channel.clone(), + message_id, + }), + WriteOutcome::Delivered { .. } | WriteOutcome::Unknown { .. } => PlaceholderStart::Unknown, + WriteOutcome::Rejected { .. } => PlaceholderStart::Rejected, + } +} + +fn delivered(outcome: &WriteOutcome) -> bool { + matches!( + outcome, + WriteOutcome::Delivered { + message_id: Some(message_id) + } if !message_id.is_empty() + ) +} + +pub(crate) async fn deliver_fresh_chunks( + adapter: &Arc, + channel: &ChannelRef, + chunks: &[String], +) -> ProgressiveDelivery { + let total = chunks.len(); + for (index, chunk) in chunks.iter().enumerate() { + match adapter.send_message_outcome(channel, chunk).await { + outcome if delivered(&outcome) => {} + WriteOutcome::Rejected { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = index, + total_chunks = total, + failed_chunk_index = index, + error_code = %code, + "ordered fresh chunk delivery rejected" + ); + return ProgressiveDelivery::rejected_chunk(index, total, index, &code); + } + WriteOutcome::Unknown { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = index, + total_chunks = total, + failed_chunk_index = index, + error_code = %code, + "ordered fresh chunk delivery outcome unknown; stopping delivery" + ); + return ProgressiveDelivery::unknown_chunk(index, total, index, &code); + } + WriteOutcome::Delivered { .. } => { + tracing::warn!( + delivered_chunks = index, + total_chunks = total, + failed_chunk_index = index, + error_code = "missing_activity_id", + "ordered fresh chunk delivery returned no activity id" + ); + return ProgressiveDelivery::unknown_chunk( + index, + total, + index, + "missing_activity_id", + ); + } + } + } + ProgressiveDelivery::default() +} + +async fn recover_rejected_placeholder( + adapter: &Arc, + channel: &ChannelRef, + placeholder: &MessageRef, + chunks: &[String], +) -> ProgressiveDelivery { + match adapter.delete_message_outcome(placeholder).await { + WriteOutcome::Unknown { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = %code, + "placeholder delete outcome unknown; not fresh-sending" + ); + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, &code) + } + WriteOutcome::Delivered { .. } => deliver_fresh_chunks(adapter, channel, chunks).await, + WriteOutcome::Rejected { code, .. } => { + tracing::warn!( + error_code = %code, + "placeholder delete rejected; fresh answer may overlap partial content" + ); + deliver_fresh_chunks(adapter, channel, chunks).await + } + } +} + +pub(crate) async fn finalize_edit_placeholder( + adapter: &Arc, + channel: &ChannelRef, + placeholder: &MessageRef, + chunks: &[String], +) -> ProgressiveDelivery { + let Some(first) = chunks.first() else { + return ProgressiveDelivery::default(); + }; + + match adapter.edit_message_outcome(placeholder, first).await { + WriteOutcome::Delivered { .. } => deliver_fresh_chunks(adapter, channel, &chunks[1..]) + .await + .with_delivered_prefix(1, chunks.len()), + WriteOutcome::Unknown { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = %code, + "final progressive edit outcome unknown; not deleting or fresh-sending" + ); + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, &code) + } + WriteOutcome::Rejected { code, .. } => { + tracing::warn!( + error_code = %code, + "final progressive edit rejected; attempting placeholder recovery" + ); + recover_rejected_placeholder(adapter, channel, placeholder, chunks).await + } + } +} + +pub(crate) async fn finalize_edit_after_cosmetic( + adapter: &Arc, + channel: &ChannelRef, + placeholder: &MessageRef, + chunks: &[String], + cosmetic: Option<&CosmeticEditState>, +) -> ProgressiveDelivery { + let Some(first) = chunks.first() else { + return ProgressiveDelivery::default(); + }; + let plan = cosmetic + .map(|state| state.final_edit_plan(first)) + .unwrap_or(FinalEditPlan::Put); + + match plan { + FinalEditPlan::Put => { + finalize_edit_placeholder(adapter, channel, placeholder, chunks).await + } + FinalEditPlan::AlreadyDelivered => deliver_fresh_chunks(adapter, channel, &chunks[1..]) + .await + .with_delivered_prefix(1, chunks.len()), + FinalEditPlan::RecoverRejected => { + tracing::warn!( + "last cosmetic edit explicitly rejected the final content; recovering without retry" + ); + recover_rejected_placeholder(adapter, channel, placeholder, chunks).await + } + FinalEditPlan::Ambiguous => { + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = "cosmetic_edit_unknown", + "last cosmetic edit may already contain final content; not retrying or recovering" + ); + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, "cosmetic_edit_unknown") + } + } +} + +pub(crate) async fn deliver_explicit_reply_chunks( + adapter: &Arc, + channel: &ChannelRef, + reply_to_message_id: &str, + chunks: &[String], +) -> ProgressiveDelivery { + let Some(first) = chunks.first() else { + return ProgressiveDelivery::default(); + }; + + match adapter + .send_message_with_reply_outcome(channel, first, reply_to_message_id) + .await + { + outcome if delivered(&outcome) => {} + WriteOutcome::Rejected { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = %code, + "ordered explicit reply rejected" + ); + return ProgressiveDelivery::rejected_chunk(0, chunks.len(), 0, &code); + } + WriteOutcome::Unknown { code, .. } => { + let code = sanitize_error_code(&code); + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = %code, + "ordered explicit reply outcome unknown" + ); + return ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, &code); + } + WriteOutcome::Delivered { .. } => { + tracing::warn!( + delivered_chunks = 0, + total_chunks = chunks.len(), + failed_chunk_index = 0, + error_code = "missing_activity_id", + "ordered explicit reply returned no activity id" + ); + return ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, "missing_activity_id"); + } + } + + deliver_fresh_chunks(adapter, channel, &chunks[1..]) + .await + .with_delivered_prefix(1, chunks.len()) +} + +pub(crate) async fn deliver_required_ack_chunks( + adapter: &Arc, + channel: &ChannelRef, + reply_to_message_id: Option<&str>, + chunks: &[String], +) -> ProgressiveDelivery { + if let Some(reply_to_message_id) = reply_to_message_id { + deliver_explicit_reply_chunks(adapter, channel, reply_to_message_id, chunks).await + } else { + deliver_fresh_chunks(adapter, channel, chunks).await + } +} + +pub(crate) async fn finalize_explicit_reply( + adapter: &Arc, + channel: &ChannelRef, + placeholder: &MessageRef, + reply_to_message_id: &str, + chunks: &[String], +) -> ProgressiveDelivery { + let delivery = + deliver_explicit_reply_chunks(adapter, channel, reply_to_message_id, chunks).await; + if delivery.failed { + return delivery; + } + + // Every final-content chunk is already authoritative at this point. Cleanup + // may leave an orphan, but it must not retry or downgrade delivered content. + match adapter.delete_message_outcome(placeholder).await { + WriteOutcome::Delivered { .. } => {} + WriteOutcome::Rejected { code, .. } => { + tracing::warn!( + error_code = %code, + "explicit reply delivered but placeholder delete was rejected" + ); + } + WriteOutcome::Unknown { code, .. } => { + tracing::warn!( + error_code = %code, + "explicit reply delivered but placeholder delete outcome is unknown" + ); + } + } + delivery +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{anyhow, Result}; + use async_trait::async_trait; + use std::collections::VecDeque; + use std::sync::Mutex; + + struct RecordingAdapter { + events: Mutex>, + sends: Mutex>, + edits: Mutex>, + deletes: Mutex>, + replies: Mutex>, + } + + impl RecordingAdapter { + fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + sends: Mutex::new(VecDeque::new()), + edits: Mutex::new(VecDeque::new()), + deletes: Mutex::new(VecDeque::new()), + replies: Mutex::new(VecDeque::new()), + } + } + + fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().expect("recording adapter mutex poisoned") + } + + fn pop_outcome(queue: &Mutex>) -> WriteOutcome { + Self::lock(queue) + .pop_front() + .expect("missing queued write outcome") + } + + fn events(&self) -> Vec { + Self::lock(&self.events).clone() + } + + fn push_send(&self, outcome: WriteOutcome) { + Self::lock(&self.sends).push_back(outcome); + } + + fn push_edit(&self, outcome: WriteOutcome) { + Self::lock(&self.edits).push_back(outcome); + } + + fn push_delete(&self, outcome: WriteOutcome) { + Self::lock(&self.deletes).push_back(outcome); + } + + fn push_reply(&self, outcome: WriteOutcome) { + Self::lock(&self.replies).push_back(outcome); + } + } + + #[async_trait] + impl ChatAdapter for RecordingAdapter { + fn platform(&self) -> &'static str { + "teams" + } + + fn message_limit(&self) -> usize { + 4096 + } + + async fn send_message(&self, _channel: &ChannelRef, _content: &str) -> Result { + Err(anyhow!("use outcome method")) + } + + async fn send_message_outcome(&self, _channel: &ChannelRef, content: &str) -> WriteOutcome { + Self::lock(&self.events).push(format!("send:{content}")); + Self::pop_outcome(&self.sends) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn edit_message_outcome(&self, _msg: &MessageRef, content: &str) -> WriteOutcome { + Self::lock(&self.events).push(format!("edit:{content}")); + Self::pop_outcome(&self.edits) + } + + async fn delete_message_outcome(&self, _msg: &MessageRef) -> WriteOutcome { + Self::lock(&self.events).push("delete".into()); + Self::pop_outcome(&self.deletes) + } + + async fn send_message_with_reply_outcome( + &self, + _channel: &ChannelRef, + content: &str, + _reply_to_message_id: &str, + ) -> WriteOutcome { + Self::lock(&self.events).push(format!("reply:{content}")); + Self::pop_outcome(&self.replies) + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + true + } + } + + fn channel() -> ChannelRef { + ChannelRef { + platform: "teams".into(), + channel_id: "conversation".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: Some("event".into()), + } + } + + fn placeholder() -> MessageRef { + MessageRef { + channel: channel(), + message_id: "placeholder".into(), + } + } + + fn delivered(id: &str) -> WriteOutcome { + WriteOutcome::Delivered { + message_id: Some(id.into()), + } + } + + fn rejected() -> WriteOutcome { + WriteOutcome::Rejected { + code: "rejected".into(), + message: "no".into(), + retry_after_ms: None, + } + } + + fn unknown() -> WriteOutcome { + WriteOutcome::Unknown { + code: "unknown".into(), + message: "maybe".into(), + } + } + + fn rejected_delivery(delivered: usize, total: usize, failed: usize) -> ProgressiveDelivery { + ProgressiveDelivery::rejected_chunk(delivered, total, failed, "rejected") + } + + fn unknown_delivery(delivered: usize, total: usize, failed: usize) -> ProgressiveDelivery { + ProgressiveDelivery::unknown_chunk(delivered, total, failed, "unknown") + } + + #[test] + fn ambiguity_marker_survives_anyhow_erasure() { + let error = anyhow::Error::new(AmbiguousProgressiveDelivery); + assert!(is_ambiguous_delivery(&error)); + assert!(!is_ambiguous_delivery(&anyhow!("ordinary failure"))); + } + + #[test] + fn cosmetic_edit_state_never_retries_the_same_failed_content() { + let mut state = CosmeticEditState::default(); + assert!(state.begin_attempt("first".into())); + assert!(!state.complete_attempt(CosmeticEditOutcome::Unknown)); + assert!(!state.begin_attempt("first".into())); + assert!(state.begin_attempt("second".into())); + assert_eq!(state.consecutive_failures(), 1); + assert_eq!(COSMETIC_EDIT_INTERVAL.as_millis(), 1500); + } + + #[test] + fn cosmetic_edit_state_stops_after_three_distinct_failures_and_success_resets() { + let mut state = CosmeticEditState::default(); + for (content, stop) in [("one", false), ("two", false), ("three", true)] { + assert!(state.begin_attempt(content.into())); + assert_eq!(state.complete_attempt(CosmeticEditOutcome::Rejected), stop); + } + + let mut reset = CosmeticEditState::default(); + assert!(reset.begin_attempt("one".into())); + assert!(!reset.complete_attempt(CosmeticEditOutcome::Unknown)); + assert!(reset.begin_attempt("two".into())); + assert!(!reset.complete_attempt(CosmeticEditOutcome::Delivered)); + assert_eq!(reset.consecutive_failures(), 0); + assert!(reset.begin_attempt("three".into())); + assert!(!reset.complete_attempt(CosmeticEditOutcome::Rejected)); + } + + #[tokio::test] + async fn delivered_same_content_is_not_put_twice() { + let adapter = Arc::new(RecordingAdapter::new()); + let erased: Arc = adapter.clone(); + let mut state = CosmeticEditState::default(); + assert!(state.begin_attempt("final".into())); + state.complete_attempt(CosmeticEditOutcome::Delivered); + + let result = finalize_edit_after_cosmetic( + &erased, + &channel(), + &placeholder(), + &["final".into()], + Some(&state), + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert!(adapter.events().is_empty()); + } + + #[tokio::test] + async fn rejected_same_content_recovers_without_repeating_put() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(delivered("fresh")); + let erased: Arc = adapter.clone(); + let mut state = CosmeticEditState::default(); + assert!(state.begin_attempt("final".into())); + state.complete_attempt(CosmeticEditOutcome::Rejected); + + let result = finalize_edit_after_cosmetic( + &erased, + &channel(), + &placeholder(), + &["final".into()], + Some(&state), + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!(adapter.events(), vec!["delete", "send:final"]); + } + + #[tokio::test] + async fn in_flight_same_content_is_ambiguous_without_another_write() { + let adapter = Arc::new(RecordingAdapter::new()); + let erased: Arc = adapter.clone(); + let mut state = CosmeticEditState::default(); + assert!(state.begin_attempt("final".into())); + + let result = finalize_edit_after_cosmetic( + &erased, + &channel(), + &placeholder(), + &["final".into()], + Some(&state), + ) + .await; + + assert_eq!( + result, + ProgressiveDelivery::unknown_chunk(0, 1, 0, "cosmetic_edit_unknown") + ); + assert!(adapter.events().is_empty()); + } + + #[tokio::test] + async fn newer_final_content_may_supersede_an_unknown_cosmetic_put() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(WriteOutcome::Delivered { message_id: None }); + let erased: Arc = adapter.clone(); + let mut state = CosmeticEditState::default(); + assert!(state.begin_attempt("partial".into())); + state.complete_attempt(CosmeticEditOutcome::Unknown); + + let result = finalize_edit_after_cosmetic( + &erased, + &channel(), + &placeholder(), + &["final".into()], + Some(&state), + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!(adapter.events(), vec!["edit:final"]); + } + + #[test] + fn placeholder_requires_a_non_empty_real_id() { + assert!(matches!( + classify_placeholder(&channel(), delivered("real")), + PlaceholderStart::Ready(message) if message.message_id == "real" + )); + assert!(matches!( + classify_placeholder(&channel(), delivered("")), + PlaceholderStart::Unknown + )); + assert!(matches!( + classify_placeholder(&channel(), rejected()), + PlaceholderStart::Rejected + )); + assert!(matches!( + classify_placeholder(&channel(), unknown()), + PlaceholderStart::Unknown + )); + } + + #[tokio::test] + async fn delivered_final_edit_sends_overflow_in_order() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(delivered("overflow-1")); + adapter.push_send(delivered("overflow-2")); + let erased: Arc = adapter.clone(); + + let result = finalize_edit_placeholder( + &erased, + &channel(), + &placeholder(), + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!( + adapter.events(), + vec!["edit:first", "send:second", "send:third"] + ); + } + + #[tokio::test] + async fn rejected_edit_and_delivered_delete_fresh_send_once() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(delivered("fresh")); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); + } + + #[tokio::test] + async fn rejected_delete_still_delivers_one_complete_fresh_answer() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(rejected()); + adapter.push_send(delivered("fresh")); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); + } + + #[tokio::test] + async fn unknown_edit_never_deletes_or_fresh_sends() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(unknown()); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, unknown_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["edit:final"]); + } + + #[tokio::test] + async fn unknown_delete_after_rejected_edit_never_fresh_sends() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(unknown()); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, unknown_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["edit:final", "delete"]); + } + + #[tokio::test] + async fn rejected_recovery_post_is_not_retried() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(rejected()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, rejected_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); + } + + #[tokio::test] + async fn unknown_recovery_post_is_not_retried() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(unknown()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; + + assert_eq!(result, unknown_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); + } + + #[tokio::test] + async fn rejected_delete_then_failed_recovery_post_is_not_retried() { + for (recovery, expected) in [ + (rejected(), rejected_delivery(0, 1, 0)), + (unknown(), unknown_delivery(0, 1, 0)), + ] { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(rejected()); + adapter.push_delete(rejected()); + adapter.push_send(recovery); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = + finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]) + .await; + + assert_eq!(result, expected); + assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); + } + } + + #[tokio::test] + async fn rejected_overflow_stops_later_chunks() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(rejected()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = finalize_edit_placeholder( + &erased, + &channel(), + &placeholder(), + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, rejected_delivery(1, 3, 1)); + assert_eq!(adapter.events(), vec!["edit:first", "send:second"]); + } + + #[tokio::test] + async fn unknown_overflow_stops_later_chunks() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_edit(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(unknown()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = finalize_edit_placeholder( + &erased, + &channel(), + &placeholder(), + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, unknown_delivery(1, 3, 1)); + assert_eq!(adapter.events(), vec!["edit:first", "send:second"]); + } + + #[tokio::test] + async fn explicit_reply_rejection_preserves_placeholder() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_reply(rejected()); + let erased: Arc = adapter.clone(); + + let result = finalize_explicit_reply( + &erased, + &channel(), + &placeholder(), + "quoted", + &["final".into()], + ) + .await; + + assert_eq!(result, rejected_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["reply:final"]); + } + + #[tokio::test] + async fn explicit_reply_unknown_preserves_placeholder() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_reply(unknown()); + let erased: Arc = adapter.clone(); + + let result = finalize_explicit_reply( + &erased, + &channel(), + &placeholder(), + "quoted", + &["final".into()], + ) + .await; + + assert_eq!(result, unknown_delivery(0, 1, 0)); + assert_eq!(adapter.events(), vec!["reply:final"]); + } + + #[tokio::test] + async fn explicit_reply_overflow_failure_preserves_placeholder() { + for (overflow, expected) in [ + (rejected(), rejected_delivery(1, 3, 1)), + (unknown(), unknown_delivery(1, 3, 1)), + ] { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_reply(delivered("reply")); + adapter.push_send(overflow); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = finalize_explicit_reply( + &erased, + &channel(), + &placeholder(), + "quoted", + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, expected); + assert_eq!(adapter.events(), vec!["reply:first", "send:second"]); + } + } + + #[tokio::test] + async fn explicit_reply_deletes_only_after_all_chunks_deliver() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_reply(delivered("reply")); + adapter.push_send(delivered("overflow")); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + let erased: Arc = adapter.clone(); + + let result = finalize_explicit_reply( + &erased, + &channel(), + &placeholder(), + "quoted", + &["first".into(), "second".into()], + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!( + adapter.events(), + vec!["reply:first", "send:second", "delete"] + ); + } + + #[tokio::test] + async fn required_ack_send_once_reports_partial_and_stops_at_middle_rejection() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_send(delivered("first")); + adapter.push_send(rejected()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = deliver_required_ack_chunks( + &erased, + &channel(), + None, + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, rejected_delivery(1, 3, 1)); + assert_eq!(adapter.events(), vec!["send:first", "send:second"]); + } + + #[tokio::test] + async fn required_ack_send_once_stops_at_middle_unknown() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_send(delivered("first")); + adapter.push_send(unknown()); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = deliver_required_ack_chunks( + &erased, + &channel(), + None, + &["first".into(), "second".into(), "third".into()], + ) + .await; + + assert_eq!(result, unknown_delivery(1, 3, 1)); + assert_eq!(adapter.events(), vec!["send:first", "send:second"]); + } + + #[tokio::test] + async fn missing_activity_id_is_unknown_and_stops_later_chunks() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_send(WriteOutcome::Delivered { message_id: None }); + adapter.push_send(delivered("must-not-send")); + let erased: Arc = adapter.clone(); + + let result = + deliver_fresh_chunks(&erased, &channel(), &["first".into(), "second".into()]).await; + + assert_eq!( + result, + ProgressiveDelivery::unknown_chunk(0, 2, 0, "missing_activity_id") + ); + assert_eq!(adapter.events(), vec!["send:first"]); + } + + #[test] + fn chunk_failure_code_is_bounded_and_sanitized() -> Result<()> { + let delivery = ProgressiveDelivery::rejected_chunk( + 1, + 3, + 1, + "bad code?!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-extra", + ); + let failure = delivery + .chunk_failure + .ok_or_else(|| anyhow!("expected chunk failure metadata"))?; + assert_eq!(failure.delivered_chunks, 1); + assert_eq!(failure.total_chunks, 3); + assert_eq!(failure.failed_chunk_index, 1); + assert!(failure.error_code.len() <= 64); + assert!(failure + .error_code + .chars() + .all(|value| value.is_ascii_alphanumeric() || matches!(value, '_' | '-'))); + Ok(()) + } + + #[tokio::test] + async fn explicit_reply_cleanup_failure_does_not_retry_or_fail_content() { + for cleanup in [rejected(), unknown()] { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.push_reply(delivered("reply")); + adapter.push_delete(cleanup); + adapter.push_delete(WriteOutcome::Delivered { message_id: None }); + let erased: Arc = adapter.clone(); + + let result = finalize_explicit_reply( + &erased, + &channel(), + &placeholder(), + "quoted", + &["final".into()], + ) + .await; + + assert_eq!(result, ProgressiveDelivery::default()); + assert_eq!(adapter.events(), vec!["reply:final", "delete"]); + } + } +} diff --git a/crates/openab-core/src/reactions.rs b/crates/openab-core/src/reactions.rs index 6e68f90b6..fde0ffc5d 100644 --- a/crates/openab-core/src/reactions.rs +++ b/crates/openab-core/src/reactions.rs @@ -137,16 +137,16 @@ impl StatusReactionController { cancel_debounce(&mut inner); let old = inner.current.clone(); inner.current = emoji.to_string(); - let adapter = inner.adapter.clone(); - let msg = inner.message.clone(); let new = emoji.to_string(); - drop(inner); - let _ = adapter.add_reaction(&msg, &new).await; + // Keep the controller lock for the complete swap. A later state must + // not overtake add(new) -> remove(old), otherwise the old status can + // become orphaned and remain visible forever. + let _ = inner.adapter.add_reaction(&inner.message, &new).await; if !old.is_empty() && old != new { - let _ = adapter.remove_reaction(&msg, &old).await; + let _ = inner.adapter.remove_reaction(&inner.message, &old).await; } - self.reset_stall_timers().await; + self.reset_stall_timers_inner(&mut inner); } async fn schedule_debounced(&self, emoji: &str) { @@ -166,15 +166,16 @@ impl StatusReactionController { if inner.finished { return; } + // The handle only owns the pending delay. Once the delay fires, + // detach this task so a later status update cannot abort it between + // adding the new reaction and removing the previous one. + let _ = inner.debounce_handle.take(); let old = inner.current.clone(); inner.current = emoji.clone(); - let adapter = inner.adapter.clone(); - let msg = inner.message.clone(); - drop(inner); - let _ = adapter.add_reaction(&msg, &emoji).await; + let _ = inner.adapter.add_reaction(&inner.message, &emoji).await; if !old.is_empty() && old != emoji { - let _ = adapter.remove_reaction(&msg, &old).await; + let _ = inner.adapter.remove_reaction(&inner.message, &old).await; } })); self.reset_stall_timers_inner(&mut inner); @@ -190,22 +191,14 @@ impl StatusReactionController { let old = inner.current.clone(); inner.current = emoji.to_string(); - let adapter = inner.adapter.clone(); - let msg = inner.message.clone(); let new = emoji.to_string(); - drop(inner); - let _ = adapter.add_reaction(&msg, &new).await; + let _ = inner.adapter.add_reaction(&inner.message, &new).await; if !old.is_empty() && old != new { - let _ = adapter.remove_reaction(&msg, &old).await; + let _ = inner.adapter.remove_reaction(&inner.message, &old).await; } } - async fn reset_stall_timers(&self) { - let mut inner = self.inner.lock().await; - self.reset_stall_timers_inner(&mut inner); - } - fn reset_stall_timers_inner(&self, inner: &mut Inner) { if let Some(h) = inner.stall_soft_handle.take() { h.abort(); @@ -226,14 +219,12 @@ impl StatusReactionController { if inner.finished { return; } + let _ = inner.stall_soft_handle.take(); let old = inner.current.clone(); inner.current = "🥱".to_string(); - let adapter = inner.adapter.clone(); - let msg = inner.message.clone(); - drop(inner); - let _ = adapter.add_reaction(&msg, "🥱").await; + let _ = inner.adapter.add_reaction(&inner.message, "🥱").await; if !old.is_empty() && old != "🥱" { - let _ = adapter.remove_reaction(&msg, &old).await; + let _ = inner.adapter.remove_reaction(&inner.message, &old).await; } } })); @@ -244,14 +235,12 @@ impl StatusReactionController { if inner.finished { return; } + let _ = inner.stall_hard_handle.take(); let old = inner.current.clone(); inner.current = "😨".to_string(); - let adapter = inner.adapter.clone(); - let msg = inner.message.clone(); - drop(inner); - let _ = adapter.add_reaction(&msg, "😨").await; + let _ = inner.adapter.add_reaction(&inner.message, "😨").await; if !old.is_empty() && old != "😨" { - let _ = adapter.remove_reaction(&msg, &old).await; + let _ = inner.adapter.remove_reaction(&inner.message, &old).await; } })); } @@ -274,3 +263,143 @@ fn cancel_timers(inner: &mut Inner) { h.abort(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::ChannelRef; + use anyhow::{anyhow, Result}; + use async_trait::async_trait; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex as StdMutex, + }; + use tokio::sync::Notify; + + struct BlockingAdapter { + events: StdMutex>, + thinking_add_started: Notify, + release_thinking_add: Notify, + blocked_once: AtomicBool, + } + + impl BlockingAdapter { + fn new() -> Self { + Self { + events: StdMutex::new(Vec::new()), + thinking_add_started: Notify::new(), + release_thinking_add: Notify::new(), + blocked_once: AtomicBool::new(false), + } + } + + fn events(&self) -> std::sync::MutexGuard<'_, Vec> { + self.events + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + } + + #[async_trait] + impl ChatAdapter for BlockingAdapter { + fn platform(&self) -> &'static str { + "test" + } + + fn message_limit(&self) -> usize { + 2_000 + } + + async fn send_message(&self, _channel: &ChannelRef, _content: &str) -> Result { + Err(anyhow!("not used")) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, emoji: &str) -> Result<()> { + self.events().push(format!("add:{emoji}")); + if emoji == "🤔" && !self.blocked_once.swap(true, Ordering::SeqCst) { + self.thinking_add_started.notify_one(); + self.release_thinking_add.notified().await; + } + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, emoji: &str) -> Result<()> { + self.events().push(format!("remove:{emoji}")); + Ok(()) + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + } + + #[tokio::test] + async fn fired_debounce_finishes_reaction_swap_when_turn_finishes() { + let adapter = Arc::new(BlockingAdapter::new()); + let message = MessageRef { + channel: ChannelRef { + platform: "test".into(), + channel_id: "channel".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: None, + }, + message_id: "message".into(), + }; + let timing = ReactionTiming { + debounce_ms: 0, + stall_soft_ms: 60_000, + stall_hard_ms: 60_000, + ..ReactionTiming::default() + }; + let controller = StatusReactionController::new( + true, + adapter.clone(), + message, + ReactionEmojis::default(), + timing, + ); + + controller.set_queued().await; + controller.set_thinking().await; + let add_started = tokio::time::timeout( + Duration::from_secs(1), + adapter.thinking_add_started.notified(), + ) + .await; + assert!(add_started.is_ok(), "thinking reaction add did not start"); + + // Finishing cancels pending timers. It must wait for a swap that has + // already started instead of overtaking or aborting it midway. + let controller = Arc::new(controller); + let finish = tokio::spawn({ + let controller = controller.clone(); + async move { controller.set_error().await } + }); + tokio::task::yield_now().await; + adapter.release_thinking_add.notify_waiters(); + + let finished = tokio::time::timeout(Duration::from_secs(1), finish).await; + assert!( + matches!(finished, Ok(Ok(()))), + "final reaction transition did not finish" + ); + let events = adapter.events(); + let queued_remove = events.iter().position(|event| event == "remove:👀"); + let error_add = events.iter().position(|event| event == "add:😱"); + assert!( + matches!((queued_remove, error_add), (Some(remove), Some(add)) if remove < add), + "final status overtook or omitted the in-flight reaction swap: {events:?}" + ); + } +} diff --git a/crates/openab-core/src/slack.rs b/crates/openab-core/src/slack.rs index 04af2d9bc..e85042800 100644 --- a/crates/openab-core/src/slack.rs +++ b/crates/openab-core/src/slack.rs @@ -465,6 +465,7 @@ impl ChatAdapter for SlackAdapter { channel_id: channel.channel_id.clone(), thread_id: channel.thread_id.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: ts.to_string(), @@ -483,6 +484,7 @@ impl ChatAdapter for SlackAdapter { channel_id: channel.channel_id.clone(), thread_id: Some(trigger_msg.message_id.clone()), parent_id: None, + persistent_conversation: None, origin_event_id: None, }) } @@ -579,6 +581,7 @@ impl ChatAdapter for SlackAdapter { channel_id: channel.channel_id.clone(), thread_id: channel.thread_id.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: ts, @@ -1075,6 +1078,7 @@ pub async fn run_slack_adapter( channel_id: channel_id.to_string(), thread_id: event["thread_ts"].as_str().map(|s| s.to_string()), parent_id: None, + persistent_conversation: None, origin_event_id: None, }; let adapter = adapter.clone(); @@ -1430,6 +1434,7 @@ async fn handle_message( channel_id: channel_id.clone(), thread_id: thread_ts.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: ts.clone(), @@ -1555,6 +1560,7 @@ async fn handle_message( channel_id: channel_id.clone(), thread_id: thread_ts.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: ts.clone(), @@ -1698,6 +1704,7 @@ async fn handle_message( channel_id: channel_id.clone(), thread_id: thread_ts.clone().or_else(|| Some(ts.clone())), parent_id: None, + persistent_conversation: None, origin_event_id: None, }; let file_list = failed_image_files @@ -1742,6 +1749,7 @@ async fn handle_message( channel_id: channel_id.clone(), thread_id: thread_ts.clone(), parent_id: None, + persistent_conversation: None, origin_event_id: None, }, message_id: ts.clone(), @@ -1753,6 +1761,7 @@ async fn handle_message( channel_id: channel_id.clone(), thread_id: Some(thread_ts.unwrap_or(ts)), parent_id: None, + persistent_conversation: None, origin_event_id: None, }; diff --git a/crates/openab-core/src/status.rs b/crates/openab-core/src/status.rs new file mode 100644 index 000000000..f99d1109a --- /dev/null +++ b/crates/openab-core/src/status.rs @@ -0,0 +1,409 @@ +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef}; +use std::sync::Arc; +use tokio::sync::Mutex; + +const PROCESSING_TEXT: &str = "⏳ Processing…"; +const MAX_TOOL_LABEL_CHARS: usize = 80; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StatusTerminal { + Completed, + Failed, + TimedOut, + DeliveryFailed, +} + +impl StatusTerminal { + fn text(self) -> &'static str { + match self { + Self::Completed => "✅ Completed", + Self::Failed => "❌ Failed", + Self::TimedOut => "⏱️ Timed out", + Self::DeliveryFailed => "❌ Delivery failed", + } + } +} + +enum State { + Idle, + Active { + message: MessageRef, + last_requested: String, + }, + Terminal { + message: MessageRef, + last_requested: String, + }, + Closed, +} + +/// One turn-local processing message. The initial send returns the only +/// activity ID this controller may edit or delete; ambiguous writes never +/// trigger a fresh status send. +pub struct StatusMessageController { + enabled: bool, + adapter: Arc, + channel: ChannelRef, + state: Mutex, +} + +impl StatusMessageController { + pub fn new(enabled: bool, adapter: Arc, channel: ChannelRef) -> Self { + Self { + enabled, + adapter, + channel, + state: Mutex::new(State::Idle), + } + } + + pub async fn set_thinking(&self) { + self.set_active(PROCESSING_TEXT).await; + } + + pub async fn set_tool(&self, tool_name: &str) { + let label = sanitize_tool_label(tool_name); + self.set_active(&format!("🛠️ Using {label}…")).await; + } + + pub async fn mark_terminal(&self, terminal: StatusTerminal) { + if !self.enabled { + return; + } + + let next = terminal.text(); + let mut state = self.state.lock().await; + let (message, last_requested) = match &*state { + State::Idle => { + *state = State::Closed; + return; + } + State::Active { + message, + last_requested, + } + | State::Terminal { + message, + last_requested, + } => (message.clone(), last_requested.clone()), + State::Closed => return, + }; + + if last_requested != next { + if let Err(error) = self.adapter.edit_message(&message, next).await { + tracing::warn!( + error = ?error, + "processing status terminal update failed" + ); + } + } + // Record the attempted state even when the PUT outcome is rejected or + // unknown. A duplicate transition must not blindly retry the same write. + *state = State::Terminal { + message, + last_requested: next.to_owned(), + }; + } + + /// Delete only after the final content is fully delivered. The status was + /// marked terminal first, so an explicit delete failure leaves recognizable + /// terminal text rather than a live processing state whenever that PUT was + /// delivered. + pub async fn clear(&self) { + if !self.enabled { + return; + } + + let mut state = self.state.lock().await; + let previous = std::mem::replace(&mut *state, State::Closed); + let message = match previous { + State::Active { message, .. } | State::Terminal { message, .. } => message, + State::Idle | State::Closed => return, + }; + if let Err(error) = self.adapter.delete_message(&message).await { + tracing::warn!(error = ?error, "processing status delete failed"); + } + } + + async fn set_active(&self, text: &str) { + if !self.enabled { + return; + } + + let mut state = self.state.lock().await; + match &*state { + State::Idle => match self.adapter.send_message(&self.channel, text).await { + Ok(message) => { + *state = State::Active { + message, + last_requested: text.to_owned(), + }; + } + Err(error) => { + // POST may have reached Teams. Disable this turn rather than + // fresh-send a duplicate status without a known activity ID. + tracing::warn!(error = ?error, "processing status create failed"); + *state = State::Closed; + } + }, + State::Active { + message, + last_requested, + } if last_requested != text => { + let message = message.clone(); + if let Err(error) = self.adapter.edit_message(&message, text).await { + tracing::warn!(error = ?error, "processing status update failed"); + } + // As with terminal PUTs, remember the attempted state so a + // duplicate event cannot turn an ambiguous failure into a retry. + *state = State::Active { + message, + last_requested: text.to_owned(), + }; + } + State::Active { .. } | State::Terminal { .. } | State::Closed => {} + } + } +} + +fn sanitize_tool_label(value: &str) -> String { + let normalized = value + .replace('\r', "") + .replace('\n', " ; ") + .replace('`', "'"); + let collapsed = normalized.split_whitespace().collect::>().join(" "); + let label = collapsed + .chars() + .take(MAX_TOOL_LABEL_CHARS) + .collect::(); + if label.is_empty() { + "tool".to_owned() + } else { + label + } +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::{anyhow, Result}; + use async_trait::async_trait; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex as StdMutex, + }; + + struct RecordingAdapter { + events: StdMutex>, + fail_send: AtomicBool, + fail_edit: AtomicBool, + fail_delete: AtomicBool, + } + + impl RecordingAdapter { + fn new() -> Self { + Self { + events: StdMutex::new(Vec::new()), + fail_send: AtomicBool::new(false), + fail_edit: AtomicBool::new(false), + fail_delete: AtomicBool::new(false), + } + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + #[async_trait] + impl ChatAdapter for RecordingAdapter { + fn platform(&self) -> &'static str { + "teams" + } + + fn message_limit(&self) -> usize { + 4096 + } + + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + self.events.lock().unwrap().push(format!( + "send:{content}:{}", + channel.origin_event_id.as_deref().unwrap_or("none") + )); + if self.fail_send.load(Ordering::SeqCst) { + return Err(anyhow!("send failed")); + } + Ok(MessageRef { + channel: channel.clone(), + message_id: "status-1".into(), + }) + } + + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger_msg: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + + async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { + self.events + .lock() + .unwrap() + .push(format!("edit:{}:{content}", msg.message_id)); + if self.fail_edit.load(Ordering::SeqCst) { + Err(anyhow!("edit failed")) + } else { + Ok(()) + } + } + + async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + self.events + .lock() + .unwrap() + .push(format!("delete:{}", msg.message_id)); + if self.fail_delete.load(Ordering::SeqCst) { + Err(anyhow!("delete failed")) + } else { + Ok(()) + } + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + } + + fn channel() -> ChannelRef { + ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + persistent_conversation: None, + origin_event_id: Some("evt-last".into()), + } + } + + #[tokio::test] + async fn lifecycle_reuses_one_real_message_and_marks_terminal_before_delete() { + let adapter = Arc::new(RecordingAdapter::new()); + let controller = StatusMessageController::new(true, adapter.clone(), channel()); + + controller.set_thinking().await; + controller.set_tool("Read\n`src/main.rs`").await; + controller.set_thinking().await; + controller.mark_terminal(StatusTerminal::Completed).await; + adapter + .send_message(&channel(), "final answer") + .await + .unwrap(); + controller.clear().await; + + assert_eq!( + adapter.events(), + vec![ + "send:⏳ Processing…:evt-last", + "edit:status-1:🛠️ Using Read ; 'src/main.rs'…", + "edit:status-1:⏳ Processing…", + "edit:status-1:✅ Completed", + "send:final answer:evt-last", + "delete:status-1", + ] + ); + } + + #[tokio::test] + async fn ambiguous_initial_send_disables_status_without_fresh_send() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.fail_send.store(true, Ordering::SeqCst); + let controller = StatusMessageController::new(true, adapter.clone(), channel()); + + controller.set_thinking().await; + controller.set_tool("bash").await; + controller.mark_terminal(StatusTerminal::Failed).await; + controller.clear().await; + + assert_eq!(adapter.events(), vec!["send:⏳ Processing…:evt-last"]); + } + + #[test] + fn terminal_text_covers_every_outcome() { + assert_eq!(StatusTerminal::Completed.text(), "✅ Completed"); + assert_eq!(StatusTerminal::Failed.text(), "❌ Failed"); + assert_eq!(StatusTerminal::TimedOut.text(), "⏱️ Timed out"); + assert_eq!( + StatusTerminal::DeliveryFailed.text(), + "❌ Delivery failed" + ); + } + + #[tokio::test] + async fn failed_put_is_not_blindly_retried() { + let adapter = Arc::new(RecordingAdapter::new()); + let controller = StatusMessageController::new(true, adapter.clone(), channel()); + + controller.set_thinking().await; + adapter.fail_edit.store(true, Ordering::SeqCst); + controller.set_tool("bash").await; + controller.set_tool("bash").await; + controller.mark_terminal(StatusTerminal::TimedOut).await; + controller.mark_terminal(StatusTerminal::TimedOut).await; + controller.clear().await; + + assert_eq!( + adapter.events(), + vec![ + "send:⏳ Processing…:evt-last", + "edit:status-1:🛠️ Using bash…", + "edit:status-1:⏱️ Timed out", + "delete:status-1", + ] + ); + } + + #[tokio::test] + async fn failed_delete_occurs_only_after_terminal_update() { + let adapter = Arc::new(RecordingAdapter::new()); + adapter.fail_delete.store(true, Ordering::SeqCst); + let controller = StatusMessageController::new(true, adapter.clone(), channel()); + + controller.set_thinking().await; + controller + .mark_terminal(StatusTerminal::DeliveryFailed) + .await; + controller.clear().await; + + assert_eq!( + adapter.events(), + vec![ + "send:⏳ Processing…:evt-last", + "edit:status-1:❌ Delivery failed", + "delete:status-1", + ] + ); + } + + #[tokio::test] + async fn disabled_controller_is_side_effect_free() { + let adapter = Arc::new(RecordingAdapter::new()); + let controller = StatusMessageController::new(false, adapter.clone(), channel()); + + controller.set_thinking().await; + controller.mark_terminal(StatusTerminal::TimedOut).await; + controller.clear().await; + + assert!(adapter.events().is_empty()); + } +} diff --git a/crates/openab-core/src/stt.rs b/crates/openab-core/src/stt.rs index d266e6117..1b0bb9ca8 100644 --- a/crates/openab-core/src/stt.rs +++ b/crates/openab-core/src/stt.rs @@ -233,6 +233,7 @@ mod tests { channel_id: "C1".into(), thread_id: Some("T1".into()), parent_id: None, + persistent_conversation: None, origin_event_id: None, } } diff --git a/crates/openab-gateway/Cargo.toml b/crates/openab-gateway/Cargo.toml index f8de597ba..f0fb2b44e 100644 --- a/crates/openab-gateway/Cargo.toml +++ b/crates/openab-gateway/Cargo.toml @@ -30,6 +30,13 @@ quick-xml = "0.37" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } parking_lot = "0.12" urlencoding = "2" +httpdate = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } [dev-dependencies] wiremock = "0.6" diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 747132e28..6e3c5ed4f 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -3909,6 +3909,9 @@ mod acp_review_fixes { command: command.map(|c| c.into()), request_id: None, quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, } } diff --git a/crates/openab-gateway/src/adapters/feishu.rs b/crates/openab-gateway/src/adapters/feishu.rs index c3df29e99..67b445032 100644 --- a/crates/openab-gateway/src/adapters/feishu.rs +++ b/crates/openab-gateway/src/adapters/feishu.rs @@ -1927,6 +1927,7 @@ pub async fn download_feishu_image( attachment_type: "image".into(), filename: format!("{}.{}", image_key, ext), mime_type: mime, + reference: None, data: String::new(), size: compressed.len() as u64, path: Some(path), @@ -2044,6 +2045,7 @@ pub async fn download_feishu_file( attachment_type: "text_file".into(), filename: file_name.to_string(), mime_type: "text/plain".into(), + reference: None, data: String::new(), size: bytes.len() as u64, path: Some(path), @@ -2151,6 +2153,7 @@ pub async fn download_feishu_audio( attachment_type: "audio".into(), filename: format!("{}.ogg", file_key), mime_type: content_type, + reference: None, data: String::new(), size: bytes.len() as u64, path: Some(path), @@ -2550,14 +2553,14 @@ pub async fn handle_reply( ); } if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: false, - thread_id: None, - message_id: None, - error: Some("invalid message_id format".to_string()), - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "invalid_target".into(), + message: "invalid message_id format".into(), + retry_after_ms: None, + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -2617,14 +2620,14 @@ pub async fn handle_reply( Err(e) => { tracing::error!(err = %e, "feishu: cannot get token for reply"); if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: false, - thread_id: None, - message_id: None, - error: Some(format!("token error: {e}")), - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "authentication_failed".into(), + message: format!("token error: {e}"), + retry_after_ms: None, + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -2673,14 +2676,12 @@ pub async fn handle_reply( } // Send response with message_id back to OAB core (for streaming edit) if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: true, - thread_id: None, - message_id: Some(msg_id), - error: None, - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Delivered { + message_id: Some(msg_id), + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -2689,14 +2690,14 @@ pub async fn handle_reply( None => { // Send failure response so core doesn't wait 5s for timeout if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: false, - thread_id: None, - message_id: None, - error: Some("send_post_message failed".into()), - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "send_failed".into(), + message: "send_post_message failed".into(), + retry_after_ms: None, + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -2747,14 +2748,21 @@ pub async fn handle_reply( "chunked send delivered {succeeded}/{total_chunks} chunks" )) }; - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success, - thread_id: None, - message_id: last_msg_id, - error, + let outcome = if success { + crate::schema::WriteOutcome::Delivered { + message_id: last_msg_id, + } + } else { + crate::schema::WriteOutcome::Rejected { + code: "partial_delivery".into(), + message: error.unwrap_or_else(|| "chunked send failed".into()), + retry_after_ms: None, + } }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + outcome, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -2776,14 +2784,16 @@ fn emit_response( error: Option, ) { if let Some(req_id) = request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success, - thread_id: None, - message_id, - error, + let outcome = if success { + crate::schema::WriteOutcome::Delivered { message_id } + } else { + crate::schema::WriteOutcome::Rejected { + code: "operation_failed".into(), + message: error.unwrap_or_else(|| "gateway operation failed".into()), + retry_after_ms: None, + } }; + let resp = crate::schema::GatewayResponse::from_write_outcome(req_id.clone(), outcome); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -4262,6 +4272,9 @@ mod tests { command: None, request_id: None, quote_message_id: Some("om_specific".into()), + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; // quote_message_id should take priority let reply_target = reply.quote_message_id.as_deref() @@ -4288,6 +4301,9 @@ mod tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4313,6 +4329,9 @@ mod tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4379,6 +4398,9 @@ mod tests { command: None, request_id: None, quote_message_id: Some("om_invalid".into()), + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4655,6 +4677,9 @@ mod tests { command: Some("edit_message".into()), request_id: Some("req_seam_1".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4663,6 +4688,8 @@ mod tests { let resp: serde_json::Value = serde_json::from_str(&raw).unwrap(); assert_eq!(resp["request_id"], "req_seam_1"); assert_eq!(resp["success"], false); + assert_eq!(resp["outcome"], "rejected"); + assert_eq!(resp["error_code"], "invalid_target"); assert_eq!(resp["error"], "invalid message_id format"); } @@ -4687,6 +4714,9 @@ mod tests { command: Some("delete_message".into()), request_id: None, quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4746,6 +4776,9 @@ mod tests { command: Some("edit_message".into()), request_id: request_id.map(|s| s.into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, } } @@ -4967,6 +5000,9 @@ mod tests { command: None, request_id: Some("r1".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; handle_reply(&reply, &adapter, &tx).await; diff --git a/crates/openab-gateway/src/adapters/googlechat.rs b/crates/openab-gateway/src/adapters/googlechat.rs index 12d274ee4..d17772969 100644 --- a/crates/openab-gateway/src/adapters/googlechat.rs +++ b/crates/openab-gateway/src/adapters/googlechat.rs @@ -384,14 +384,14 @@ impl GoogleChatAdapter { "googlechat reply (dry-run, no credentials configured)" ); if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: false, - thread_id: None, - message_id: None, - error: Some("no credentials configured".into()), - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "not_configured".into(), + message: "no credentials configured".into(), + retry_after_ms: None, + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -405,14 +405,14 @@ impl GoogleChatAdapter { // Empty message: short-circuit, send failure ack and skip API call if chunks.is_empty() { if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: false, - thread_id: None, - message_id: None, - error: Some("empty message".into()), - }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "invalid_request".into(), + message: "empty message".into(), + retry_after_ms: None, + }, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -432,18 +432,20 @@ impl GoogleChatAdapter { .await; if let Some(ref req_id) = reply.request_id { - let (success, message_id, error) = match result { - Ok(name) => (true, Some(name), None), - Err(e) => (false, None, Some(e)), - }; - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success, - thread_id: None, - message_id, - error, + let outcome = match result { + Ok(name) => crate::schema::WriteOutcome::Delivered { + message_id: Some(name), + }, + Err(message) => crate::schema::WriteOutcome::Rejected { + code: "send_failed".into(), + message, + retry_after_ms: None, + }, }; + let resp = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + outcome, + ); if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); } @@ -475,13 +477,29 @@ impl GoogleChatAdapter { } } if let Some(ref req_id) = reply.request_id { - let resp = crate::schema::GatewayResponse { - schema: "openab.gateway.response.v1".into(), - request_id: req_id.clone(), - success: first_msg_name.is_some() && first_error.is_none(), - thread_id: None, - message_id: first_msg_name, - error: first_error, + let resp = match (first_msg_name, first_error) { + (Some(message_id), None) => { + crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Delivered { + message_id: Some(message_id), + }, + ) + } + (message_id, error) => { + let mut response = crate::schema::GatewayResponse::from_write_outcome( + req_id.clone(), + crate::schema::WriteOutcome::Rejected { + code: "partial_delivery".into(), + message: error.unwrap_or_else(|| "no message delivered".into()), + retry_after_ms: None, + }, + ); + // Preserve the first successful ID for legacy diagnostics; + // the structured outcome remains rejected and is not retried. + response.message_id = message_id; + response + } }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -1367,6 +1385,7 @@ pub async fn download_googlechat_image( attachment_type: "image".into(), filename: content_name.to_string(), mime_type: mime, + reference: None, data: String::new(), size: compressed.len() as u64, path: Some(path), @@ -1475,6 +1494,7 @@ pub async fn download_googlechat_file( attachment_type: "text_file".into(), filename: content_name.to_string(), mime_type: "text/plain".into(), + reference: None, data: String::new(), size: bytes.len() as u64, path: Some(path), @@ -1571,6 +1591,7 @@ pub async fn download_googlechat_audio( attachment_type: "audio".into(), filename: content_name.to_string(), mime_type: content_type.to_string(), + reference: None, data: String::new(), size: bytes.len() as u64, path: Some(path), @@ -2051,6 +2072,9 @@ mod tests { command: None, request_id: Some("req_123".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2060,6 +2084,7 @@ mod tests { let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap(); assert_eq!(resp.request_id, "req_123"); assert!(resp.success); + assert_eq!(resp.outcome, Some(crate::schema::WriteOutcomeKind::Delivered)); assert_eq!(resp.message_id, Some("spaces/TEST/messages/msg_abc".into())); } @@ -2095,6 +2120,9 @@ mod tests { command: None, request_id: Some("req_fail".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2104,6 +2132,8 @@ mod tests { let resp: GatewayResponse = serde_json::from_str(&received.unwrap()).unwrap(); assert_eq!(resp.request_id, "req_fail"); assert!(!resp.success); + assert_eq!(resp.outcome, Some(crate::schema::WriteOutcomeKind::Rejected)); + assert_eq!(resp.error_code.as_deref(), Some("send_failed")); assert!(resp.message_id.is_none()); let err = resp.error.expect("error should be set on send failure"); assert!(err.contains("500"), "error should include status code, got: {}", err); @@ -2143,6 +2173,9 @@ mod tests { command: None, request_id: Some("req_empty".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2188,6 +2221,9 @@ mod tests { command: None, request_id: Some("req_multi_fail".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2223,6 +2259,9 @@ mod tests { command: None, request_id: Some("req_notoken".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2269,6 +2308,9 @@ mod tests { command: Some("edit_message".into()), request_id: None, quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2312,6 +2354,9 @@ mod tests { command: None, request_id: Some("req_multi".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2370,6 +2415,9 @@ mod tests { command: None, request_id: Some("req_partial".into()), quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, }; adapter.handle_reply(&reply, &event_tx).await; diff --git a/crates/openab-gateway/src/adapters/line.rs b/crates/openab-gateway/src/adapters/line.rs index 10101e939..b21e794c0 100644 --- a/crates/openab-gateway/src/adapters/line.rs +++ b/crates/openab-gateway/src/adapters/line.rs @@ -237,6 +237,7 @@ async fn build_gateway_event_from_line_event( "LINE external image content is not supported yet" ); attachments.push(Attachment { + reference: None, attachment_type: "image".into(), filename: format!("line_{}.jpg", msg.id), mime_type: "image/jpeg".into(), @@ -254,6 +255,7 @@ async fn build_gateway_event_from_line_event( } else { warn!(message_id = %msg.id, "LINE image received but LINE_CHANNEL_ACCESS_TOKEN is not configured"); attachments.push(Attachment { + reference: None, attachment_type: "image".into(), filename: format!("line_{}.jpg", msg.id), mime_type: "image/jpeg".into(), @@ -285,6 +287,7 @@ async fn build_gateway_event_from_line_event( "LINE external audio content is not supported yet" ); attachments.push(Attachment { + reference: None, attachment_type: "audio".into(), filename: format!("line_{}.audio", msg.id), mime_type: "audio/ogg".into(), @@ -302,6 +305,7 @@ async fn build_gateway_event_from_line_event( } else { warn!(message_id = %msg.id, "LINE audio received but LINE_CHANNEL_ACCESS_TOKEN is not configured"); attachments.push(Attachment { + reference: None, attachment_type: "audio".into(), filename: format!("line_{}.audio", msg.id), mime_type: "audio/ogg".into(), @@ -405,6 +409,7 @@ pub async fn download_line_image( api_base: &str, ) -> Attachment { let rejected = |size: u64, reason: String| Attachment { + reference: None, attachment_type: "image".into(), filename: format!("line_{}.jpg", message_id), mime_type: "image/jpeg".into(), @@ -498,6 +503,7 @@ pub async fn download_line_image( }; let ext = if mime == "image/gif" { "gif" } else { "jpg" }; Attachment { + reference: None, attachment_type: "image".into(), filename: format!("line_{}.{}", message_id, ext), mime_type: mime, @@ -515,6 +521,7 @@ pub async fn download_line_audio( api_base: &str, ) -> Attachment { let rejected = |filename: String, mime_type: String, size: u64, reason: String| Attachment { + reference: None, attachment_type: "audio".into(), filename, mime_type, @@ -628,6 +635,7 @@ pub async fn download_line_audio( }; Attachment { + reference: None, attachment_type: "audio".into(), filename, mime_type: content_type, @@ -1301,6 +1309,8 @@ mod tests { let cache: crate::ReplyTokenCache = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); let reply = GatewayReply { + attachment_ref: None, + persistent_conversation: None, schema: "openab.gateway.reply.v1".into(), reply_to: "evt1".into(), platform: "line".into(), @@ -1316,6 +1326,7 @@ mod tests { command: Some("edit_message".into()), request_id: None, quote_message_id: None, + target_message_id: None, }; let used_reply = dispatch_line_reply( diff --git a/crates/openab-gateway/src/adapters/lineworks.rs b/crates/openab-gateway/src/adapters/lineworks.rs index 036542c36..1dea72186 100644 --- a/crates/openab-gateway/src/adapters/lineworks.rs +++ b/crates/openab-gateway/src/adapters/lineworks.rs @@ -787,6 +787,7 @@ async fn download_attachment( Some(path) => { let ext = if mime == "image/gif" { "gif" } else { "jpg" }; Attachment { + reference: None, attachment_type: "image".into(), filename: format!("lineworks_{file_id}.{ext}"), mime_type: mime, @@ -831,6 +832,7 @@ async fn download_attachment( Some(path) => { let ext = audio_extension(&ct); Attachment { + reference: None, attachment_type: "audio".into(), filename: format!("lineworks_{file_id}.{ext}"), mime_type: ct, @@ -877,6 +879,7 @@ async fn download_attachment( match fetch_attachment_bytes(adapter, file_id, FILE_MAX_DOWNLOAD).await { Ok((bytes, _ct)) => match store::store_media(&bytes).await { Some(path) => Attachment { + reference: None, attachment_type: "text_file".into(), filename, mime_type: "text/plain".into(), @@ -2129,6 +2132,8 @@ mod tests { fn text_reply(channel_id: &str, text: &str, command: Option<&str>) -> GatewayReply { GatewayReply { + attachment_ref: None, + persistent_conversation: None, schema: "openab.gateway.reply.v1".into(), reply_to: "evt_1".into(), platform: "lineworks".into(), @@ -2144,6 +2149,7 @@ mod tests { command: command.map(Into::into), request_id: None, quote_message_id: None, + target_message_id: None, } } diff --git a/crates/openab-gateway/src/adapters/mod.rs b/crates/openab-gateway/src/adapters/mod.rs index 105f51081..eb7754b26 100644 --- a/crates/openab-gateway/src/adapters/mod.rs +++ b/crates/openab-gateway/src/adapters/mod.rs @@ -11,6 +11,10 @@ pub mod googlechat; #[cfg(feature = "wecom")] pub mod wecom; #[cfg(feature = "teams")] +pub(crate) mod teams_ingress; +#[cfg(feature = "teams")] +pub(crate) mod teams_registry; +#[cfg(feature = "teams")] pub mod teams; #[cfg(feature = "acp")] pub mod acp_server; diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 0ee438d11..611b610b8 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,16 +1,31 @@ +use super::teams_ingress::{ + wait_for_publish, AttachmentLookupError, OwnershipLookupError, PublishReservation, + PublishState, ReactionLookupError, RouteLookupError, TeamsAttachmentSource, + TeamsAttachmentSourceKind, TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, + TeamsRouteKey, DEFAULT_DEDUPE_TTL_SECS, DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, +}; +use super::teams_registry::{ + key_from_parts, PromotionKind, TeamsConversationKey, TeamsConversationRegistry, + DEFAULT_CONVERSATION_REGISTRY_MAX_ENTRIES, DEFAULT_CONVERSATION_REGISTRY_TTL_SECS, +}; use crate::schema::*; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; +use base64::Engine; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::Deserialize; -use std::sync::Arc; -use tokio::sync::RwLock; +use std::borrow::Cow; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; use tracing::{debug, error, info, warn}; // --- Bot Framework activity types --- #[allow(dead_code)] // Bot Framework schema fields — needed for future features -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Activity { #[serde(rename = "type")] @@ -20,14 +35,21 @@ pub struct Activity { pub service_url: Option, pub channel_id: Option, pub from: Option, + pub recipient: Option, pub conversation: Option, pub text: Option, pub tenant: Option, pub channel_data: Option, + pub reply_to_id: Option, + pub action: Option, + #[serde(default)] + pub entities: Vec, + #[serde(default)] + pub attachments: Vec, } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelAccount { pub id: Option, @@ -35,8 +57,27 @@ pub struct ChannelAccount { pub aad_object_id: Option, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityEntity { + #[serde(default, rename = "type")] + pub entity_type: String, + pub mentioned: Option, + pub text: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivityAttachment { + #[serde(default)] + pub content_type: String, + pub content_url: Option, + pub name: Option, + pub content: Option, +} + #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConversationAccount { pub id: Option, @@ -45,17 +86,26 @@ pub struct ConversationAccount { pub tenant_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TenantInfo { pub id: Option, } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelData { pub tenant: Option, + pub team: Option, + pub channel: Option, +} + +#[allow(dead_code)] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelDataEntity { + pub id: Option, } impl Activity { @@ -76,6 +126,119 @@ impl Activity { .and_then(|c| c.tenant_id.as_deref()) }) } + + fn missing_required_message_field(&self) -> Option<&'static str> { + let present = |value: Option<&str>| value.is_some_and(|value| !value.trim().is_empty()); + if !present(self.channel_id.as_deref()) { + return Some("channelId"); + } + if !present(self.resolved_tenant_id()) { + return Some("tenant id"); + } + if !present( + self.conversation + .as_ref() + .and_then(|conversation| conversation.id.as_deref()), + ) { + return Some("conversation id"); + } + if !present(self.id.as_deref()) { + return Some("activity id"); + } + if !present(self.from.as_ref().and_then(|sender| sender.id.as_deref())) { + return Some("sender id"); + } + if !present(self.service_url.as_deref()) { + return Some("serviceUrl"); + } + None + } + + fn recipient_info(&self) -> Option { + let recipient = self.recipient.as_ref()?; + let id = recipient.id.as_deref().filter(|id| !id.trim().is_empty())?; + Some(RecipientInfo { + id: id.to_owned(), + name: recipient.name.clone().unwrap_or_default(), + }) + } + + fn mention_info(&self) -> (Vec, Vec) { + let mut mention_ids = Vec::new(); + let mut mention_entities = Vec::new(); + for entity in &self.entities { + if !entity.entity_type.eq_ignore_ascii_case("mention") { + continue; + } + let Some(id) = entity + .mentioned + .as_ref() + .and_then(|mentioned| mentioned.id.as_deref()) + .filter(|id| !id.trim().is_empty()) + else { + continue; + }; + if !mention_ids.iter().any(|known| known == id) { + mention_ids.push(id.to_owned()); + } + mention_entities.push(MentionInfo { + id: id.to_owned(), + text: entity.text.clone().unwrap_or_default(), + }); + } + (mention_ids, mention_entities) + } + + fn gateway_scope( + &self, + tenant_id: &str, + conversation_id: &str, + conversation_type: &str, + ) -> GatewayScope { + let team_id = self + .channel_data + .as_ref() + .and_then(|data| data.team.as_ref()) + .and_then(|team| team.id.clone()) + .filter(|id| !id.trim().is_empty()); + let channel_id = self + .channel_data + .as_ref() + .and_then(|data| data.channel.as_ref()) + .and_then(|channel| channel.id.clone()) + .filter(|id| !id.trim().is_empty()); + let trust_scope_id = match conversation_type { + "personal" => format!("teams:{tenant_id}:personal:{conversation_id}"), + "groupChat" => format!("teams:{tenant_id}:group-chat:{conversation_id}"), + "channel" => match (team_id.as_deref(), channel_id.as_deref()) { + (Some(team), Some(channel)) => { + format!("teams:{tenant_id}:team:{team}:channel:{channel}") + } + _ => format!("teams:{tenant_id}:invalid-channel:{conversation_id}"), + }, + other => format!("teams:{tenant_id}:unknown:{other}:{conversation_id}"), + }; + GatewayScope { + tenant_id: Some(tenant_id.to_owned()), + team_id, + channel_id, + conversation_type: conversation_type.to_owned(), + trust_scope_id, + is_dm: conversation_type == "personal", + } + } +} + +fn canonical_conversation_type(value: &str) -> String { + if value.eq_ignore_ascii_case("personal") { + "personal".into() + } else if value.eq_ignore_ascii_case("groupChat") { + "groupChat".into() + } else if value.eq_ignore_ascii_case("channel") { + "channel".into() + } else { + value.trim().to_owned() + } } // --- OpenID configuration --- @@ -110,17 +273,38 @@ struct TokenResponse { struct CachedToken { token: String, - expires_at: std::time::Instant, + expires_at: Instant, +} + +#[derive(Clone)] +struct CachedOpenId { + jwks_uri: reqwest::Url, + fetched_at: Instant, +} + +#[derive(Clone)] +struct CachedJwks { + keys: Vec, + fetched_at: Instant, } // --- Teams adapter config --- +#[derive(Clone)] pub struct TeamsConfig { pub app_id: String, pub app_secret: String, pub oauth_endpoint: String, pub openid_metadata: String, pub allowed_tenants: Vec, + pub dedupe_ttl_secs: u64, + pub route_ttl_secs: u64, + pub max_route_entries: usize, + pub reactions_enabled: bool, + pub inbound_attachments: bool, + pub conversation_registry_path: Option, + pub conversation_registry_max_entries: usize, + pub conversation_registry_ttl_secs: u64, } impl TeamsConfig { @@ -149,48 +333,672 @@ impl TeamsConfig { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), + dedupe_ttl_secs: parse_positive_u64( + read("TEAMS_DEDUPE_TTL_SECS"), + "TEAMS_DEDUPE_TTL_SECS", + DEFAULT_DEDUPE_TTL_SECS, + ), + route_ttl_secs: parse_positive_u64( + read("TEAMS_ROUTE_TTL_SECS"), + "TEAMS_ROUTE_TTL_SECS", + DEFAULT_ROUTE_TTL_SECS, + ), + max_route_entries: parse_positive_usize( + read("TEAMS_MAX_ROUTE_ENTRIES"), + "TEAMS_MAX_ROUTE_ENTRIES", + DEFAULT_MAX_ROUTE_ENTRIES, + ), + reactions_enabled: parse_opt_in_bool( + read("TEAMS_REACTIONS_ENABLED"), + "TEAMS_REACTIONS_ENABLED", + ), + inbound_attachments: parse_opt_in_bool( + read("TEAMS_INBOUND_ATTACHMENTS"), + "TEAMS_INBOUND_ATTACHMENTS", + ), + conversation_registry_path: read("TEAMS_CONVERSATION_REGISTRY_PATH") + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + conversation_registry_max_entries: parse_positive_usize( + read("TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES"), + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", + DEFAULT_CONVERSATION_REGISTRY_MAX_ENTRIES, + ), + conversation_registry_ttl_secs: parse_positive_u64( + read("TEAMS_CONVERSATION_REGISTRY_TTL_SECS"), + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS", + DEFAULT_CONVERSATION_REGISTRY_TTL_SECS, + ), }) } } +fn parse_opt_in_bool(raw: Option, key: &str) -> bool { + match raw.as_deref().map(str::trim) { + None | Some("") | Some("0") => false, + Some(value) if value.eq_ignore_ascii_case("false") => false, + Some("1") => true, + Some(value) if value.eq_ignore_ascii_case("true") => true, + Some(_) => { + warn!(key, "invalid opt-in Teams boolean; using false"); + false + } + } +} + +fn parse_positive_u64(raw: Option, key: &str, default: u64) -> u64 { + match raw.as_deref().map(str::trim) { + None | Some("") => default, + Some(value) => match value.parse::() { + Ok(value) if value > 0 => value, + _ => { + warn!( + key, + default, "invalid positive Teams runtime setting; using default" + ); + default + } + }, + } +} + +fn parse_positive_usize(raw: Option, key: &str, default: usize) -> usize { + match raw.as_deref().map(str::trim) { + None | Some("") => default, + Some(value) => match value.parse::() { + Ok(value) if value > 0 => value, + _ => { + warn!( + key, + default, "invalid positive Teams runtime setting; using default" + ); + default + } + }, + } +} + // --- Teams adapter state --- pub struct TeamsAdapter { config: TeamsConfig, client: reqwest::Client, + attachment_client: reqwest::Client, token_cache: RwLock>, - jwks_cache: RwLock, std::time::Instant)>>, + token_refresh_lock: Mutex<()>, + openid_cache: RwLock>, + openid_refresh_lock: Mutex<()>, + jwks_cache: RwLock>, + jwks_refresh_lock: Mutex<()>, + ingress: Mutex, + conversation_registry: Option>>, + conversation_writes: Vec>, + allow_non_public_endpoints: bool, + #[cfg(test)] + persistent_service_url_override: StdMutex>, +} + +const AUTH_CACHE_TTL: Duration = Duration::from_secs(3600); +const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(300); +const TEAMS_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const TEAMS_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const TEAMS_ERROR_BODY_LIMIT: usize = 4 * 1024; +const TEAMS_MAX_REDIRECTS: usize = 5; +const TEAMS_WRITE_SHARDS: usize = 64; +const TEAMS_ATTACHMENT_METADATA_LIMIT: usize = 10; +const TEAMS_IMAGE_DOWNLOAD_LIMIT: u64 = 10 * 1024 * 1024; +const TEAMS_TEXT_DOWNLOAD_LIMIT: u64 = 512 * 1024; +const TEAMS_MATERIALIZED_FRAME_LIMIT: usize = 8 * 1024 * 1024; +const TEAMS_FILENAME_LIMIT: usize = 200; +const TEAMS_MUTATION_RETRY_MAX_DELAY: Duration = Duration::from_secs(1); +const TEAMS_PUBLIC_SERVICE_HOST: &str = "smba.trafficmanager.net"; +const TEAMS_PUBLIC_OAUTH_HOST: &str = "login.microsoftonline.com"; +const TEAMS_PUBLIC_OPENID_HOST: &str = "login.botframework.com"; + +#[derive(Clone, Copy)] +enum ConnectorWriteBody<'a> { + Absent, + Empty, + Json(&'a serde_json::Value), +} + +#[derive(Clone, Copy)] +struct ConnectorWritePolicy { + operation: &'static str, + allow_rate_limit_retry: bool, } -const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600); -const TOKEN_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(300); +impl ConnectorWritePolicy { + const fn reactive(operation: &'static str) -> Self { + Self { + operation, + allow_rate_limit_retry: true, + } + } + + const fn persistent(operation: &'static str) -> Self { + Self { + operation, + allow_rate_limit_retry: false, + } + } +} impl TeamsAdapter { pub fn new(config: TeamsConfig) -> Self { + Self::with_client( + config, + build_http_client(TEAMS_REQUEST_TIMEOUT), + false, + TEAMS_REQUEST_TIMEOUT, + ) + } + + fn with_client( + config: TeamsConfig, + client: reqwest::Client, + allow_non_public_endpoints: bool, + attachment_timeout: Duration, + ) -> Self { + if config.reactions_enabled { + warn!("teams message reactions are enabled through a Microsoft public-preview API"); + } + let ingress = TeamsIngressRegistry::new( + Duration::from_secs(config.dedupe_ttl_secs), + Duration::from_secs(config.route_ttl_secs), + config.max_route_entries, + ); + let conversation_registry = config + .conversation_registry_path + .as_deref() + .and_then(|path| { + match TeamsConversationRegistry::open( + path, + config.conversation_registry_max_entries, + config.conversation_registry_ttl_secs, + ) { + Ok(registry) => { + let counts = registry.counts(); + info!( + active = counts.active, + disabled = counts.disabled, + revoked = counts.revoked, + "teams conversation registry loaded" + ); + Some(Arc::new(StdMutex::new(registry))) + } + Err(error) => { + error!(error = %error, "teams conversation registry unavailable"); + None + } + } + }); Self { config, - client: reqwest::Client::new(), + client, + attachment_client: build_attachment_http_client(attachment_timeout), token_cache: RwLock::new(None), + token_refresh_lock: Mutex::new(()), + openid_cache: RwLock::new(None), + openid_refresh_lock: Mutex::new(()), jwks_cache: RwLock::new(None), + jwks_refresh_lock: Mutex::new(()), + ingress: Mutex::new(ingress), + conversation_registry, + conversation_writes: (0..TEAMS_WRITE_SHARDS).map(|_| Mutex::new(())).collect(), + allow_non_public_endpoints, + #[cfg(test)] + persistent_service_url_override: StdMutex::new(None), } } - /// Get a valid OAuth bearer token, refreshing if needed. - async fn get_token(&self) -> anyhow::Result { - // Check cache + #[cfg(test)] + pub(crate) fn new_for_test(config: TeamsConfig) -> Self { + Self::with_client( + config, + build_http_client(TEAMS_REQUEST_TIMEOUT), + true, + TEAMS_REQUEST_TIMEOUT, + ) + } + + #[cfg(test)] + fn new_for_test_with_timeout(config: TeamsConfig, request_timeout: Duration) -> Self { + Self::with_client( + config, + build_http_client(request_timeout), + true, + request_timeout, + ) + } + + #[cfg(test)] + pub(crate) async fn accept_route_for_test( + &self, + service_url: &str, + event_id: &str, + tenant_id: &str, + conversation_id: &str, + activity_id: &str, + reply_chain_root_id: Option<&str>, + ) -> anyhow::Result<()> { + let now = Instant::now(); + let route_key = TeamsRouteKey::new( + self.config.app_id.clone(), + tenant_id, + conversation_id, + activity_id, + ); + let route = TeamsIngressRoute { + key: route_key.clone(), + event_id: event_id.into(), + tenant_id: tenant_id.into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: conversation_id.into(), + conversation_type: "personal".into(), + inbound_activity_id: activity_id.into(), + reply_chain_root_id: reply_chain_root_id.map(str::to_owned), + service_url: reqwest::Url::parse(service_url)?, + team_id: None, + channel_id: None, + attachment_sources: HashMap::new(), + attachment_materialized_bytes: 0, + created_at: now, + }; + let mut ingress = self.ingress.lock().await; + assert!(matches!( + ingress.reserve(route_key.clone(), event_id.into(), now), + PublishReservation::Owner + )); + assert!(ingress.accept(&route_key, event_id, route, now)); + Ok(()) + } + + #[cfg(test)] + pub(crate) async fn accept_text_attachment_route_for_test( + &self, + service_url: &str, + event_id: &str, + conversation_id: &str, + activity_id: &str, + reference: &str, + download_url: &str, + ) -> anyhow::Result<()> { + let now = Instant::now(); + let route_key = TeamsRouteKey::new( + self.config.app_id.clone(), + "tenant-1", + conversation_id, + activity_id, + ); + let service_origin = reqwest::Url::parse(service_url)?; + let mut attachment_sources = HashMap::new(); + attachment_sources.insert( + reference.into(), + TeamsAttachmentSource { + kind: TeamsAttachmentSourceKind::PersonalTextFile, + url: reqwest::Url::parse(download_url)?, + service_origin: service_origin.clone(), + attachment_type: "text_file".into(), + filename: "notes.txt".into(), + mime_type: "text/plain; charset=utf-8".into(), + max_bytes: TEAMS_TEXT_DOWNLOAD_LIMIT, + }, + ); + let route = TeamsIngressRoute { + key: route_key.clone(), + event_id: event_id.into(), + tenant_id: "tenant-1".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: conversation_id.into(), + conversation_type: "personal".into(), + inbound_activity_id: activity_id.into(), + reply_chain_root_id: None, + service_url: service_origin, + team_id: None, + channel_id: None, + attachment_sources, + attachment_materialized_bytes: 0, + created_at: now, + }; + let mut ingress = self.ingress.lock().await; + assert!(matches!( + ingress.reserve(route_key.clone(), event_id.into(), now), + PublishReservation::Owner + )); + assert!(ingress.accept(&route_key, event_id, route, now)); + Ok(()) + } + + pub(crate) async fn cleanup_ingress(&self) -> TeamsIngressCleanupStats { + self.ingress.lock().await.cleanup(Instant::now()) + } + + pub fn reactions_enabled(&self) -> bool { + self.config.reactions_enabled + } + + pub fn inbound_attachments_enabled(&self) -> bool { + self.config.inbound_attachments + } + + pub fn conversation_registry_available(&self) -> bool { + self.conversation_registry.is_some() + } + + async fn resolve_persistent_route( + &self, + target: &PersistentConversationTarget, + ) -> Result<(TeamsConversationKey, TeamsIngressRoute), WriteOutcome> { + if target.bot_framework_channel_id != "msteams" + || (!self.config.allowed_tenants.is_empty() + && !self + .config + .allowed_tenants + .iter() + .any(|tenant| tenant == &target.tenant_id)) + { + return Err(rejected_outcome( + "persistent_target_rejected", + "Teams persistent conversation target is unavailable", + )); + } + let key = key_from_parts( + &self.config.app_id, + &target.tenant_id, + &target.bot_framework_channel_id, + &target.conversation_id, + ) + .map_err(|_| { + rejected_outcome( + "persistent_target_invalid", + "Teams persistent conversation target is invalid", + ) + })?; + let Some(registry) = self.conversation_registry.clone() else { + return Err(rejected_outcome( + "conversation_registry_unavailable", + "Teams persistent conversation registry is unavailable", + )); + }; + let lookup_key = key.clone(); + let entry = match tokio::task::spawn_blocking(move || { + let registry = registry + .lock() + .map_err(|_| anyhow::anyhow!("conversation registry lock is poisoned"))?; + Ok::<_, anyhow::Error>(registry.active(&lookup_key, chrono::Utc::now())) + }) + .await + { + Ok(Ok(Some(entry))) => entry, + Ok(Ok(None)) => { + return Err(rejected_outcome( + "persistent_route_unavailable", + "Teams persistent conversation route is unavailable", + )) + } + Ok(Err(_)) | Err(_) => { + return Err(rejected_outcome( + "conversation_registry_unavailable", + "Teams persistent conversation registry is unavailable", + )) + } + }; + let service_url = self.validate_service_url(&entry.service_url).map_err(|_| { + rejected_outcome( + "persistent_route_invalid", + "Teams persistent conversation route is invalid", + ) + })?; + #[cfg(test)] + let service_url = if self.allow_non_public_endpoints { + self.persistent_service_url_override + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + .unwrap_or(service_url) + } else { + service_url + }; + let route = TeamsIngressRoute { + key: TeamsRouteKey::new( + key.app_id.clone(), + key.tenant_id.clone(), + key.conversation_id.clone(), + String::new(), + ), + event_id: String::new(), + tenant_id: key.tenant_id.clone(), + bot_framework_channel_id: key.bot_framework_channel_id.clone(), + conversation_id: key.conversation_id.clone(), + conversation_type: entry.conversation_type, + inbound_activity_id: String::new(), + reply_chain_root_id: None, + service_url, + team_id: entry.team_id, + channel_id: entry.channel_id, + attachment_sources: HashMap::new(), + attachment_materialized_bytes: 0, + created_at: Instant::now(), + }; + Ok((key, route)) + } + + async fn reconcile_persistent_write(&self, key: &TeamsConversationKey, outcome: &WriteOutcome) { + let reason_code = match outcome { + WriteOutcome::Delivered { .. } => None, + WriteOutcome::Rejected { code, .. } + if matches!( + code.as_str(), + "message_writes_blocked" | "bot_not_in_conversation_roster" + ) => + { + Some(code.clone()) + } + _ => return, + }; + let Some(registry) = self.conversation_registry.clone() else { + return; + }; + let key = key.clone(); + let transition = reason_code.clone(); + let result = tokio::task::spawn_blocking(move || { + let mut registry = registry + .lock() + .map_err(|_| anyhow::anyhow!("conversation registry lock is poisoned"))?; + let changed = match transition.as_deref() { + Some(reason) => { + registry.record_forbidden_write(&key, reason, chrono::Utc::now())? + } + None => registry.record_success(&key, chrono::Utc::now())?, + }; + Ok::<_, anyhow::Error>((changed, registry.counts())) + }) + .await; + match result { + Ok(Ok((changed, counts))) => { + if changed { + info!( + operation = if reason_code.is_some() { + "forbidden_write" + } else { + "delivery_reset" + }, + reason_code = reason_code.as_deref().unwrap_or("delivered"), + active = counts.active, + disabled = counts.disabled, + revoked = counts.revoked, + "teams persistent conversation state reconciled" + ); + } + } + Ok(Err(_)) | Err(_) => { + error!( + operation = "persistent_registry_reconciliation", + "teams persistent conversation reconciliation failed" + ); + } + } + } + + async fn register_conversation(&self, event_id: &str, conversation_id: &str) -> WriteOutcome { + let route = + { + let mut ingress = self.ingress.lock().await; + match ingress.route_for_registration(event_id, conversation_id, Instant::now()) { + Ok(route) => route, + Err(RouteLookupError::NotFound) => { + return rejected_outcome( + "origin_route_not_found", + "conversation registration route is missing or expired", + ) + } + Err(RouteLookupError::ConversationMismatch) => return rejected_outcome( + "conversation_mismatch", + "conversation registration scope does not match the authenticated route", + ), + } + }; + let Some(registry) = self.conversation_registry.clone() else { + return rejected_outcome( + "conversation_registry_unavailable", + "Teams conversation registry is unavailable", + ); + }; + + match tokio::task::spawn_blocking(move || { + let mut registry = registry + .lock() + .map_err(|_| anyhow::anyhow!("conversation registry lock is poisoned"))?; + let promotion = registry.promote(&route, chrono::Utc::now())?; + Ok::<_, anyhow::Error>((promotion, registry.counts())) + }) + .await { - let cache = self.token_cache.read().await; - if let Some(ref cached) = *cache { - if cached.expires_at > std::time::Instant::now() + TOKEN_REFRESH_MARGIN { - return Ok(cached.token.clone()); + Ok(Ok((promotion, counts))) => { + info!( + operation = match promotion { + PromotionKind::Inserted => "inserted", + PromotionKind::Refreshed => "refreshed", + PromotionKind::Reactivated => "reactivated", + }, + active = counts.active, + disabled = counts.disabled, + revoked = counts.revoked, + "teams conversation registration committed" + ); + WriteOutcome::Delivered { message_id: None } + } + Ok(Err(error)) => { + error!(error = %error, "teams conversation registration outcome is unknown"); + WriteOutcome::Unknown { + code: "conversation_registry_write_unknown".into(), + message: "Teams conversation registration outcome is unknown".into(), } } + Err(error) => { + error!(error = %error, "teams conversation registration task failed"); + WriteOutcome::Unknown { + code: "conversation_registry_task_failed".into(), + message: "Teams conversation registration task failed".into(), + } + } + } + } + + async fn revoke_installed_conversation( + &self, + tenant_id: &str, + bot_framework_channel_id: &str, + conversation_id: &str, + team_id: Option<&str>, + ) -> anyhow::Result { + let Some(registry) = self.conversation_registry.clone() else { + return Ok(false); + }; + let key = key_from_parts( + &self.config.app_id, + tenant_id, + bot_framework_channel_id, + conversation_id, + )?; + let team_id = team_id.map(str::to_owned); + tokio::task::spawn_blocking(move || { + let mut registry = registry + .lock() + .map_err(|_| anyhow::anyhow!("conversation registry lock is poisoned"))?; + match team_id.as_deref() { + Some(team_id) => registry + .revoke_scope( + &key, + Some(team_id), + "installation_remove", + chrono::Utc::now(), + ) + .map(|changed| changed > 0), + None => registry.revoke(&key, "installation_remove", chrono::Utc::now()), + } + }) + .await + .map_err(|_| anyhow::anyhow!("conversation registry revocation task failed"))? + } + + #[cfg(test)] + pub(crate) fn conversation_registry_counts( + &self, + ) -> Option { + self.conversation_registry.as_ref().map(|registry| { + registry + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .counts() + }) + } + + fn conversation_write_shard(route: &TeamsIngressRoute) -> usize { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + route.tenant_id.hash(&mut hasher); + route.conversation_id.hash(&mut hasher); + (hasher.finish() as usize) % TEAMS_WRITE_SHARDS + } + + async fn lock_conversation<'a>( + &'a self, + route: &TeamsIngressRoute, + ) -> tokio::sync::MutexGuard<'a, ()> { + self.conversation_writes[Self::conversation_write_shard(route)] + .lock() + .await + } + + async fn cached_token(&self) -> Option { + let cache = self.token_cache.read().await; + cache.as_ref().and_then(|cached| { + (cached.expires_at > Instant::now() + TOKEN_REFRESH_MARGIN) + .then(|| cached.token.clone()) + }) + } + + /// Get a valid OAuth bearer token, refreshing once for concurrent callers. + async fn get_token(&self) -> anyhow::Result { + if let Some(token) = self.cached_token().await { + return Ok(token); + } + + let _refresh_guard = self.token_refresh_lock.lock().await; + if let Some(token) = self.cached_token().await { + return Ok(token); } - // Fetch new token - let resp: TokenResponse = self + let endpoint = validate_public_cloud_endpoint( + &self.config.oauth_endpoint, + "Teams OAuth endpoint", + TEAMS_PUBLIC_OAUTH_HOST, + self.allow_non_public_endpoints, + )?; + let response = self .client - .post(&self.config.oauth_endpoint) + .post(endpoint) .form(&[ ("grant_type", "client_credentials"), ("client_id", &self.config.app_id), @@ -198,57 +1006,143 @@ impl TeamsAdapter { ("scope", "https://api.botframework.com/.default"), ]) .send() - .await? + .await + .map_err(|error| safe_request_error("Teams OAuth request", &error))?; + let response = require_http_success( + response, + "Teams OAuth request", + &[self.config.app_secret.as_str()], + ) + .await?; + let response: TokenResponse = response .json() - .await?; + .await + .map_err(|_| anyhow::anyhow!("Teams OAuth response was not valid JSON"))?; + if response.access_token.is_empty() { + anyhow::bail!("Teams OAuth response missing access token"); + } + let expires_at = Instant::now() + .checked_add(Duration::from_secs(response.expires_in)) + .ok_or_else(|| anyhow::anyhow!("Teams OAuth expiry is out of range"))?; - let token = resp.access_token.clone(); + let token = response.access_token.clone(); *self.token_cache.write().await = Some(CachedToken { - token: resp.access_token, - expires_at: std::time::Instant::now() + std::time::Duration::from_secs(resp.expires_in), + token: response.access_token, + expires_at, }); info!("teams OAuth token refreshed"); Ok(token) } - /// Fetch and cache JWKS signing keys from Microsoft's OpenID metadata. - async fn get_jwks(&self) -> anyhow::Result> { - { - let cache = self.jwks_cache.read().await; - if let Some((ref keys, fetched_at)) = *cache { - if fetched_at.elapsed() < JWKS_CACHE_TTL { - return Ok(keys.clone()); - } - } + async fn cached_openid(&self) -> Option { + let cache = self.openid_cache.read().await; + cache + .as_ref() + .filter(|cached| cached.fetched_at.elapsed() < AUTH_CACHE_TTL) + .cloned() + } + + /// Resolve and cache Microsoft's JWKS endpoint, with one metadata request + /// shared by all concurrent callers after cache expiry. + async fn get_openid_jwks_uri(&self) -> anyhow::Result { + if let Some(cached) = self.cached_openid().await { + return Ok(cached.jwks_uri); + } + + let _refresh_guard = self.openid_refresh_lock.lock().await; + if let Some(cached) = self.cached_openid().await { + return Ok(cached.jwks_uri); } - let config: OpenIdConfig = self + let endpoint = validate_public_cloud_endpoint( + &self.config.openid_metadata, + "Teams OpenID metadata endpoint", + TEAMS_PUBLIC_OPENID_HOST, + self.allow_non_public_endpoints, + )?; + let response = self .client - .get(&self.config.openid_metadata) + .get(endpoint) .send() - .await? + .await + .map_err(|error| safe_request_error("Teams OpenID metadata request", &error))?; + let response = require_http_success(response, "Teams OpenID metadata request", &[]).await?; + let config: OpenIdConfig = response .json() - .await?; + .await + .map_err(|_| anyhow::anyhow!("Teams OpenID metadata was not valid JSON"))?; + let jwks_uri = validate_public_cloud_endpoint( + &config.jwks_uri, + "Teams JWKS endpoint", + TEAMS_PUBLIC_OPENID_HOST, + self.allow_non_public_endpoints, + )?; + + *self.openid_cache.write().await = Some(CachedOpenId { + jwks_uri: jwks_uri.clone(), + fetched_at: Instant::now(), + }); + Ok(jwks_uri) + } + + async fn cached_jwks(&self) -> Option { + let cache = self.jwks_cache.read().await; + cache + .as_ref() + .filter(|cached| cached.fetched_at.elapsed() < AUTH_CACHE_TTL) + .cloned() + } - let jwks: JwksResponse = self + async fn fetch_jwks(&self) -> anyhow::Result { + let endpoint = self.get_openid_jwks_uri().await?; + let response = self .client - .get(&config.jwks_uri) + .get(endpoint) .send() - .await? + .await + .map_err(|error| safe_request_error("Teams JWKS request", &error))?; + let response = require_http_success(response, "Teams JWKS request", &[]).await?; + let jwks: JwksResponse = response .json() - .await?; + .await + .map_err(|_| anyhow::anyhow!("Teams JWKS response was not valid JSON"))?; + if jwks.keys.is_empty() { + anyhow::bail!("Teams JWKS response contained no keys"); + } + + let cached = CachedJwks { + keys: jwks.keys, + fetched_at: Instant::now(), + }; + *self.jwks_cache.write().await = Some(cached.clone()); + info!(count = cached.keys.len(), "teams JWKS keys refreshed"); + Ok(cached) + } + + /// Fetch and cache JWKS signing keys, sharing one refresh among concurrent + /// webhook callers after cache expiry. + async fn get_jwks(&self) -> anyhow::Result { + if let Some(cached) = self.cached_jwks().await { + return Ok(cached); + } - let keys = jwks.keys; - *self.jwks_cache.write().await = Some((keys.clone(), std::time::Instant::now())); - info!(count = keys.len(), "teams JWKS keys refreshed"); - Ok(keys) + let _refresh_guard = self.jwks_refresh_lock.lock().await; + if let Some(cached) = self.cached_jwks().await { + return Ok(cached); + } + self.fetch_jwks().await } - /// Force-refresh JWKS keys, bypassing cache TTL. Called on cache miss (kid not found). - async fn refresh_jwks(&self) -> anyhow::Result> { - // Invalidate cache so get_jwks fetches fresh - *self.jwks_cache.write().await = None; - self.get_jwks().await + /// Refresh keys after a `kid` miss. The observed generation prevents a + /// burst of concurrent misses from issuing sequential duplicate refreshes. + async fn refresh_jwks(&self, observed_at: Instant) -> anyhow::Result { + let _refresh_guard = self.jwks_refresh_lock.lock().await; + if let Some(cached) = self.jwks_cache.read().await.as_ref() { + if cached.fetched_at != observed_at { + return Ok(cached.clone()); + } + } + self.fetch_jwks().await } /// Validate the JWT bearer token from an inbound Bot Framework request. @@ -264,16 +1158,21 @@ impl TeamsAdapter { .kid .ok_or_else(|| anyhow::anyhow!("no kid in JWT header"))?; - let keys = self.get_jwks().await?; - let key = match keys.iter().find(|k| k.kid.as_deref() == Some(&kid)) { - Some(k) => k.clone(), + let snapshot = self.get_jwks().await?; + let key = match snapshot + .keys + .iter() + .find(|key| key.kid.as_deref() == Some(&kid)) + { + Some(key) => key.clone(), None => { // Cache miss: Microsoft may have rotated keys. Force refresh and retry. - let refreshed = self.refresh_jwks().await?; + let refreshed = self.refresh_jwks(snapshot.fetched_at).await?; refreshed + .keys .into_iter() - .find(|k| k.kid.as_deref() == Some(&kid)) - .ok_or_else(|| anyhow::anyhow!("no matching JWK for kid={kid} after refresh"))? + .find(|key| key.kid.as_deref() == Some(&kid)) + .ok_or_else(|| anyhow::anyhow!("no matching JWK after refresh"))? } }; @@ -282,16 +1181,19 @@ impl TeamsAdapter { } // B2: Validate channel endorsements — key must endorse the activity's channelId - let channel_id = activity.channel_id.as_deref() + let channel_id = activity + .channel_id + .as_deref() .ok_or_else(|| anyhow::anyhow!("activity missing channelId"))?; if key.endorsements.is_empty() { anyhow::bail!("JWK has no endorsements — cannot verify channelId={channel_id}"); } - if !key.endorsements.iter().any(|e| e == channel_id) { - anyhow::bail!( - "JWK endorsements {:?} do not include channelId={channel_id}", - key.endorsements - ); + if !key + .endorsements + .iter() + .any(|endorsement| endorsement == channel_id) + { + anyhow::bail!("JWK does not endorse activity channelId"); } let decoding_key = DecodingKey::from_rsa_components(&key.n, &key.e)?; @@ -299,7 +1201,7 @@ impl TeamsAdapter { validation.set_audience(&[&self.config.app_id]); // Bot Framework tokens can use RS256 or RS384 validation.algorithms = vec![Algorithm::RS256, Algorithm::RS384]; - // Bot Framework issuer per auth spec + // M0 supports the Microsoft commercial public-cloud issuer only. validation.set_issuer(&["https://api.botframework.com"]); validation.validate_aud = true; validation.validate_exp = true; @@ -307,16 +1209,19 @@ impl TeamsAdapter { let token_data = decode::(token, &decoding_key, &validation)?; - // B1: Validate serviceUrl claim matches activity's serviceUrl - let activity_service_url = activity.service_url.as_deref() + // B1: Validate serviceUrl claim matches activity's serviceUrl without + // copying either full URL into an error that will be logged. + let activity_service_url = activity + .service_url + .as_deref() .ok_or_else(|| anyhow::anyhow!("activity missing serviceUrl"))?; - let token_service_url = token_data.claims.get("serviceurl") - .and_then(|v| v.as_str()) + let token_service_url = token_data + .claims + .get("serviceurl") + .and_then(|value| value.as_str()) .ok_or_else(|| anyhow::anyhow!("JWT missing serviceurl claim"))?; if token_service_url != activity_service_url { - anyhow::bail!( - "serviceUrl mismatch: token={token_service_url}, activity={activity_service_url}" - ); + anyhow::bail!("serviceUrl claim does not match activity"); } Ok(()) @@ -329,23 +1234,81 @@ impl TeamsAdapter { } activity .resolved_tenant_id() - .is_some_and(|tid| self.config.allowed_tenants.iter().any(|a| a == tid)) + .is_some_and(|tenant_id| self.config.allowed_tenants.iter().any(|a| a == tenant_id)) } - /// Send a reply via Bot Framework REST API. - pub async fn send_activity( + fn connector_url( + &self, + service_url: &str, + conversation_id: &str, + activity_id: Option<&str>, + ) -> anyhow::Result { + connector_url( + service_url, + conversation_id, + activity_id, + self.allow_non_public_endpoints, + ) + } + + fn reaction_url( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction_type: &str, + ) -> anyhow::Result { + reaction_url( + service_url, + conversation_id, + activity_id, + reaction_type, + self.allow_non_public_endpoints, + ) + } + + fn validate_service_url(&self, service_url: &str) -> anyhow::Result { + validate_public_cloud_endpoint( + service_url, + "Teams service URL", + TEAMS_PUBLIC_SERVICE_HOST, + self.allow_non_public_endpoints, + ) + } + + /// Send a reply via Bot Framework REST API and preserve whether a failed + /// POST was rejected or may already have reached Teams. + pub async fn send_activity_outcome( &self, service_url: &str, conversation_id: &str, text: &str, reply_to_id: Option<&str>, - ) -> anyhow::Result { - let token = self.get_token().await?; - let url = format!( - "{}v3/conversations/{}/activities", - ensure_trailing_slash(service_url), - conversation_id - ); + ) -> WriteOutcome { + // Bot Connector distinguishes a plain conversation send from a reply + // by endpoint. A route-scoped quote must use ReplyToActivity; setting + // only Activity.replyToId on SendToConversation is not sufficient for + // Teams clients to render the reply relationship. + let url = match self.connector_url(service_url, conversation_id, reply_to_id) { + Ok(url) => url, + Err(error) => { + return WriteOutcome::Rejected { + code: "invalid_route".into(), + message: error.to_string(), + retry_after_ms: None, + }; + } + }; + let token = match self.get_token().await { + Ok(token) => token, + Err(error) => { + return WriteOutcome::Rejected { + code: "connector_auth_failed".into(), + message: error.to_string(), + retry_after_ms: None, + }; + } + }; let mut body = serde_json::json!({ "type": "message", @@ -357,354 +1320,3810 @@ impl TeamsAdapter { body["replyToId"] = serde_json::Value::String(id.to_string()); } - let resp = self + let response = match self .client - .post(&url) + .post(url) .bearer_auth(&token) .json(&body) .send() - .await?; + .await + { + Ok(response) => response, + Err(error) => { + let code = if error.is_timeout() { + "request_timeout" + } else { + "transport_error" + }; + return WriteOutcome::Unknown { + code: code.into(), + message: safe_request_error("Bot Framework send", &error).to_string(), + }; + } + }; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("Bot Framework API error {status}: {body}"); + let status = response.status(); + if status.is_success() { + let result: serde_json::Value = match response.json().await { + Ok(result) => result, + Err(_) => { + return WriteOutcome::Unknown { + code: "invalid_success_response".into(), + message: "Bot Framework send succeeded without a valid JSON response" + .into(), + }; + } + }; + return match result + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + { + Some(activity_id) => WriteOutcome::Delivered { + message_id: Some(activity_id.to_owned()), + }, + None => WriteOutcome::Unknown { + code: "missing_activity_id".into(), + message: "Bot Framework send response missing activity id".into(), + }, + }; } - let result: serde_json::Value = resp.json().await?; - Ok(result["id"].as_str().unwrap_or("").to_string()) + classify_write_failure(response, "Bot Framework send", &[token.as_str()]).await } - /// Edit an existing activity (for streaming updates). - pub async fn update_activity( + /// Compatibility wrapper for callers that predate structured outcomes. + pub async fn send_activity( + &self, + service_url: &str, + conversation_id: &str, + text: &str, + reply_to_id: Option<&str>, + ) -> anyhow::Result { + match self + .send_activity_outcome(service_url, conversation_id, text, reply_to_id) + .await + { + WriteOutcome::Delivered { + message_id: Some(message_id), + } => Ok(message_id), + WriteOutcome::Delivered { message_id: None } => { + anyhow::bail!("Bot Framework send response missing activity id") + } + WriteOutcome::Rejected { message, .. } | WriteOutcome::Unknown { message, .. } => { + Err(anyhow::anyhow!(message)) + } + } + } + + async fn idempotent_connector_write_outcome( + &self, + method: reqwest::Method, + url: reqwest::Url, + body: ConnectorWriteBody<'_>, + policy: ConnectorWritePolicy, + ) -> WriteOutcome { + let token = match self.get_token().await { + Ok(token) => token, + Err(error) => { + return WriteOutcome::Rejected { + code: "connector_auth_failed".into(), + message: error.to_string(), + retry_after_ms: None, + }; + } + }; + + let mut retried_rate_limit = false; + loop { + let mut request = self + .client + .request(method.clone(), url.clone()) + .bearer_auth(&token); + request = match body { + ConnectorWriteBody::Absent => request, + ConnectorWriteBody::Empty => request.header(reqwest::header::CONTENT_LENGTH, "0"), + ConnectorWriteBody::Json(body) => request.json(body), + }; + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + let code = if error.is_timeout() { + "request_timeout" + } else { + "transport_error" + }; + return WriteOutcome::Unknown { + code: code.into(), + message: safe_request_error(policy.operation, &error).to_string(), + }; + } + }; + if response.status().is_success() { + return WriteOutcome::Delivered { message_id: None }; + } + + let outcome = + classify_write_failure(response, policy.operation, &[token.as_str()]).await; + if policy.allow_rate_limit_retry && !retried_rate_limit { + if let WriteOutcome::Rejected { + code, + retry_after_ms: Some(delay_ms), + .. + } = &outcome + { + let delay = Duration::from_millis(*delay_ms); + if code == "rate_limited" && delay <= TEAMS_MUTATION_RETRY_MAX_DELAY { + warn!( + operation = policy.operation, + retry_after_ms = *delay_ms, + "teams: retrying rate-limited Connector write once" + ); + retried_rate_limit = true; + tokio::time::sleep(delay).await; + continue; + } + } + } + return outcome; + } + } + + async fn mutate_activity_outcome( + &self, + method: reqwest::Method, + service_url: &str, + conversation_id: &str, + activity_id: &str, + body: Option<&serde_json::Value>, + policy: ConnectorWritePolicy, + ) -> WriteOutcome { + let url = match self.connector_url(service_url, conversation_id, Some(activity_id)) { + Ok(url) => url, + Err(error) => { + return WriteOutcome::Rejected { + code: "invalid_route".into(), + message: error.to_string(), + retry_after_ms: None, + }; + } + }; + let body = body.map_or(ConnectorWriteBody::Absent, ConnectorWriteBody::Json); + self.idempotent_connector_write_outcome(method, url, body, policy) + .await + } + + pub async fn update_activity_outcome( &self, service_url: &str, conversation_id: &str, activity_id: &str, text: &str, - ) -> anyhow::Result<()> { - let token = self.get_token().await?; - let url = format!( - "{}v3/conversations/{}/activities/{}", - ensure_trailing_slash(service_url), + ) -> WriteOutcome { + let body = serde_json::json!({ + "type": "message", + "from": { "id": &self.config.app_id }, + "text": text, + "textFormat": "markdown", + }); + self.mutate_activity_outcome( + reqwest::Method::PUT, + service_url, conversation_id, - activity_id - ); + activity_id, + Some(&body), + ConnectorWritePolicy::reactive("Bot Framework update"), + ) + .await + } + + async fn update_activity_outcome_without_retry( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + text: &str, + ) -> WriteOutcome { + let body = serde_json::json!({ + "type": "message", + "from": { "id": &self.config.app_id }, + "text": text, + "textFormat": "markdown", + }); + self.mutate_activity_outcome( + reqwest::Method::PUT, + service_url, + conversation_id, + activity_id, + Some(&body), + ConnectorWritePolicy::persistent("Bot Framework update"), + ) + .await + } + + pub async fn delete_activity_outcome( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + ) -> WriteOutcome { + self.mutate_activity_outcome( + reqwest::Method::DELETE, + service_url, + conversation_id, + activity_id, + None, + ConnectorWritePolicy::reactive("Bot Framework delete"), + ) + .await + } + + async fn delete_activity_outcome_without_retry( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + ) -> WriteOutcome { + self.mutate_activity_outcome( + reqwest::Method::DELETE, + service_url, + conversation_id, + activity_id, + None, + ConnectorWritePolicy::persistent("Bot Framework delete"), + ) + .await + } + + async fn reaction_activity_outcome( + &self, + method: reqwest::Method, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + policy: ConnectorWritePolicy, + ) -> WriteOutcome { + let Some(reaction_type) = teams_reaction_type(reaction) else { + return WriteOutcome::Rejected { + code: "unsupported_reaction".into(), + message: "Teams reaction is not a supported emoji or reaction ID".into(), + retry_after_ms: None, + }; + }; + let url = match self.reaction_url( + service_url, + conversation_id, + activity_id, + reaction_type.as_ref(), + ) { + Ok(url) => url, + Err(error) => { + return WriteOutcome::Rejected { + code: "invalid_route".into(), + message: error.to_string(), + retry_after_ms: None, + }; + } + }; + self.idempotent_connector_write_outcome(method, url, ConnectorWriteBody::Empty, policy) + .await + } + + pub async fn add_reaction_outcome( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + ) -> WriteOutcome { + self.reaction_activity_outcome( + reqwest::Method::PUT, + service_url, + conversation_id, + activity_id, + reaction, + ConnectorWritePolicy::reactive("Bot Framework add reaction"), + ) + .await + } + + async fn add_reaction_outcome_without_retry( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + ) -> WriteOutcome { + self.reaction_activity_outcome( + reqwest::Method::PUT, + service_url, + conversation_id, + activity_id, + reaction, + ConnectorWritePolicy::persistent("Bot Framework add reaction"), + ) + .await + } + + pub async fn remove_reaction_outcome( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + ) -> WriteOutcome { + self.reaction_activity_outcome( + reqwest::Method::DELETE, + service_url, + conversation_id, + activity_id, + reaction, + ConnectorWritePolicy::reactive("Bot Framework remove reaction"), + ) + .await + } + + async fn remove_reaction_outcome_without_retry( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + ) -> WriteOutcome { + self.reaction_activity_outcome( + reqwest::Method::DELETE, + service_url, + conversation_id, + activity_id, + reaction, + ConnectorWritePolicy::persistent("Bot Framework remove reaction"), + ) + .await + } + + /// Compatibility wrapper for callers that predate structured outcomes. + pub async fn update_activity( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + text: &str, + ) -> anyhow::Result<()> { + write_outcome_to_result( + self.update_activity_outcome(service_url, conversation_id, activity_id, text) + .await, + ) + } + + /// Compatibility wrapper for callers that predate structured outcomes. + pub async fn delete_activity( + &self, + service_url: &str, + conversation_id: &str, + activity_id: &str, + ) -> anyhow::Result<()> { + write_outcome_to_result( + self.delete_activity_outcome(service_url, conversation_id, activity_id) + .await, + ) + } +} + +fn build_http_client(request_timeout: Duration) -> reqwest::Client { + let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= TEAMS_MAX_REDIRECTS { + return attempt.stop(); + } + let Some(previous) = attempt.previous().last() else { + return attempt.stop(); + }; + let target = attempt.url(); + let same_origin = previous.scheme() == target.scheme() + && previous.host_str() == target.host_str() + && previous.port_or_known_default() == target.port_or_known_default(); + let safe_authority = target.username().is_empty() && target.password().is_none(); + if same_origin && safe_authority { + attempt.follow() + } else { + attempt.stop() + } + }); + + reqwest::Client::builder() + .connect_timeout(TEAMS_CONNECT_TIMEOUT) + .timeout(request_timeout) + .redirect(redirect_policy) + .build() + .unwrap_or_else(|error| panic!("teams: failed to build hardened HTTP client: {error}")) +} + +fn build_attachment_http_client(request_timeout: Duration) -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(TEAMS_CONNECT_TIMEOUT) + .timeout(request_timeout) + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_else(|error| panic!("teams: failed to build attachment HTTP client: {error}")) +} + +fn validate_public_cloud_endpoint( + raw_url: &str, + label: &str, + expected_host: &str, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + let url = + reqwest::Url::parse(raw_url).map_err(|_| anyhow::anyhow!("{label} is not a valid URL"))?; + if !url.username().is_empty() || url.password().is_some() { + anyhow::bail!("{label} must not contain userinfo"); + } + if url.query().is_some() || url.fragment().is_some() { + anyhow::bail!("{label} must not contain a query or fragment"); + } + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("{label} is missing a host"))?; + + if allow_non_public_endpoints { + if !matches!(url.scheme(), "http" | "https") { + anyhow::bail!("{label} must use HTTP or HTTPS in tests"); + } + return Ok(url); + } + + if url.scheme() != "https" { + anyhow::bail!("{label} must use HTTPS"); + } + if host.parse::().is_ok() { + anyhow::bail!("{label} must not use an IP literal"); + } + if !host.eq_ignore_ascii_case(expected_host) { + anyhow::bail!("{label} host is not allowed for Microsoft public cloud"); + } + if url.port_or_known_default() != Some(443) { + anyhow::bail!("{label} must use HTTPS port 443"); + } + Ok(url) +} + +fn connector_url( + service_url: &str, + conversation_id: &str, + activity_id: Option<&str>, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + validate_connector_id(conversation_id, "conversation ID")?; + if let Some(activity_id) = activity_id { + validate_connector_id(activity_id, "activity ID")?; + } + + let mut url = validate_public_cloud_endpoint( + service_url, + "Teams service URL", + TEAMS_PUBLIC_SERVICE_HOST, + allow_non_public_endpoints, + )?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("Teams service URL cannot be used as a base URL"))?; + segments.pop_if_empty(); + segments + .push("v3") + .push("conversations") + .push(conversation_id) + .push("activities"); + if let Some(activity_id) = activity_id { + segments.push(activity_id); + } + } + Ok(url) +} + +fn teams_reaction_type(value: &str) -> Option> { + let mapped = match value { + "👀" => "1f440_eyes", + "🤔" => "think", + "🔥" => "fire", + "👨‍💻" => "mantechie", + "⚡" | "⚡️" => "26a1_highvoltagesign", + "🆗" => "1f197_squaredok", + "🥱" => "1f971_yawningface", + "😨" => "fearful", + "😱" => "screamingfear", + "😊" => "smileeyes", + "😎" => "cool", + "🫡" => "salute", + "🤓" => "nerdy", + "😏" => "smirk", + "✌" | "✌️" => "victory", + "💪" => "muscle", + "🦾" => "1f9be_mechanicalarm", + "👍" => "like", + "❤" | "❤️" => "heart", + "✅" => "2705_whiteheavycheckmark", + "❌" => "274c_crossmark", + "⏳" => "holdon", + value + if value.len() <= 128 + && !value.is_empty() + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-') + }) => + { + return Some(Cow::Borrowed(value)); + } + _ => return None, + }; + Some(Cow::Borrowed(mapped)) +} + +fn reaction_url( + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction_type: &str, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + validate_connector_id(reaction_type, "reaction type")?; + let mut url = connector_url( + service_url, + conversation_id, + Some(activity_id), + allow_non_public_endpoints, + )?; + let mut segments = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("Teams service URL cannot be used as a base URL"))?; + segments.push("reactions").push(reaction_type); + drop(segments); + Ok(url) +} + +fn validate_connector_id(id: &str, label: &str) -> anyhow::Result<()> { + if id.is_empty() { + anyhow::bail!("Teams {label} must not be empty"); + } + if matches!(id, "." | "..") { + anyhow::bail!("Teams {label} must not be a dot segment"); + } + Ok(()) +} + +fn safe_request_error(operation: &str, error: &reqwest::Error) -> anyhow::Error { + let kind = if error.is_timeout() { + "timed out" + } else if error.is_connect() { + "connection failed" + } else if error.is_redirect() { + "redirect failed" + } else { + "request failed" + }; + anyhow::anyhow!("{operation} {kind}") +} + +fn parse_retry_after_ms(headers: &reqwest::header::HeaderMap) -> Option { + let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?; + if let Ok(seconds) = value.parse::() { + return Some(seconds.saturating_mul(1000)); + } + + let retry_at = httpdate::parse_http_date(value).ok()?; + let delay = retry_at + .duration_since(std::time::SystemTime::now()) + .unwrap_or_default(); + Some(delay.as_millis().min(u128::from(u64::MAX)) as u64) +} + +async fn classify_write_failure( + response: reqwest::Response, + operation: &str, + sensitive_values: &[&str], +) -> WriteOutcome { + let status = response.status(); + let retry_after_ms = (status == StatusCode::TOO_MANY_REQUESTS) + .then(|| parse_retry_after_ms(response.headers())) + .flatten(); + let body = read_bounded_error_body(response, sensitive_values).await; + let message = format!("{operation} failed with HTTP {status}: {}", body.display); + if status.is_server_error() { + WriteOutcome::Unknown { + code: "connector_server_error".into(), + message, + } + } else { + let code = if status == StatusCode::FORBIDDEN { + body.exact_forbidden_code + .unwrap_or("authorization_rejected") + } else { + match status.as_u16() { + 401 => "authorization_rejected", + 413 => "message_too_large", + 429 => "rate_limited", + 300..=399 => "redirect_rejected", + _ => "connector_rejected", + } + }; + WriteOutcome::Rejected { + code: code.into(), + message, + retry_after_ms, + } + } +} + +fn write_outcome_to_result(outcome: WriteOutcome) -> anyhow::Result<()> { + match outcome { + WriteOutcome::Delivered { .. } => Ok(()), + WriteOutcome::Rejected { message, .. } | WriteOutcome::Unknown { message, .. } => { + Err(anyhow::anyhow!(message)) + } + } +} + +async fn require_http_success( + response: reqwest::Response, + operation: &str, + sensitive_values: &[&str], +) -> anyhow::Result { + if response.status().is_success() { + return Ok(response); + } + + let status = response.status(); + let body = read_bounded_error_body(response, sensitive_values).await; + anyhow::bail!("{operation} failed with HTTP {status}: {}", body.display) +} + +struct BoundedConnectorError { + display: String, + exact_forbidden_code: Option<&'static str>, +} + +fn exact_forbidden_code(bytes: &[u8]) -> Option<&'static str> { + let value: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let code = value + .pointer("/error/code") + .or_else(|| value.get("code"))? + .as_str()?; + match code { + "MessageWritesBlocked" => Some("message_writes_blocked"), + "BotNotInConversationRoster" => Some("bot_not_in_conversation_roster"), + _ => None, + } +} + +async fn read_bounded_error_body( + mut response: reqwest::Response, + sensitive_values: &[&str], +) -> BoundedConnectorError { + let mut bytes = Vec::with_capacity(TEAMS_ERROR_BODY_LIMIT); + let mut truncated = false; + loop { + match response.chunk().await { + Ok(Some(chunk)) => { + let remaining = TEAMS_ERROR_BODY_LIMIT.saturating_sub(bytes.len()); + if remaining == 0 { + truncated = true; + break; + } + let take = remaining.min(chunk.len()); + bytes.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + truncated = true; + break; + } + } + Ok(None) => break, + Err(_) => { + truncated = true; + break; + } + } + } + + let exact_forbidden_code = exact_forbidden_code(&bytes); + let mut redacted = match String::from_utf8(bytes) { + Ok(text) => redact_sensitive_text(&text, sensitive_values), + Err(_) => "[non-UTF-8 error body]".into(), + }; + truncate_utf8(&mut redacted, TEAMS_ERROR_BODY_LIMIT); + if redacted.is_empty() { + redacted.push_str(""); + } + if truncated { + redacted.push_str(" [truncated]"); + } + BoundedConnectorError { + display: redacted, + exact_forbidden_code, + } +} + +fn redact_sensitive_text(input: &str, sensitive_values: &[&str]) -> String { + let mut value = match serde_json::from_str::(input) { + Ok(mut value) => { + redact_sensitive_json(&mut value, sensitive_values); + serde_json::to_string(&value).unwrap_or_else(|_| "[REDACTED]".into()) + } + Err(_) => input.to_string(), + }; + + value = redact_urls(&value); + for sensitive in sensitive_values.iter().filter(|value| !value.is_empty()) { + value = value.replace(sensitive, "[REDACTED]"); + } + for marker in [ + "bearer ", + "access_token=", + "access_token:", + "access_token\":\"", + "refresh_token=", + "refresh_token:", + "refresh_token\":\"", + "client_secret=", + "client_secret:", + "client_secret\":\"", + "authorization\":\"", + ] { + value = redact_value_after_marker(&value, marker); + } + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect() +} + +fn redact_sensitive_json(value: &mut serde_json::Value, sensitive_values: &[&str]) { + match value { + serde_json::Value::Object(object) => { + for (key, value) in object { + let key = key.to_ascii_lowercase(); + if key.contains("token") || key.contains("secret") || key == "authorization" { + *value = serde_json::Value::String("[REDACTED]".into()); + } else { + redact_sensitive_json(value, sensitive_values); + } + } + } + serde_json::Value::Array(values) => { + for value in values { + redact_sensitive_json(value, sensitive_values); + } + } + serde_json::Value::String(string) => { + *string = redact_urls(string); + for sensitive in sensitive_values.iter().filter(|value| !value.is_empty()) { + *string = string.replace(sensitive, "[REDACTED]"); + } + } + _ => {} + } +} + +fn redact_urls(input: &str) -> String { + let lower = input.to_ascii_lowercase(); + let mut output = String::with_capacity(input.len()); + let mut cursor = 0; + while cursor < input.len() { + let http = lower[cursor..] + .find("http://") + .map(|offset| cursor + offset); + let https = lower[cursor..] + .find("https://") + .map(|offset| cursor + offset); + let Some(start) = [http, https].into_iter().flatten().min() else { + output.push_str(&input[cursor..]); + break; + }; + output.push_str(&input[cursor..start]); + output.push_str("[REDACTED_URL]"); + let mut end = input.len(); + for (offset, character) in input[start..].char_indices() { + if offset > 0 + && (character.is_whitespace() + || matches!( + character, + '"' | '\'' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' + )) + { + end = start + offset; + break; + } + } + cursor = end; + } + output +} + +fn redact_value_after_marker(input: &str, marker: &str) -> String { + let lower = input.to_ascii_lowercase(); + let mut output = String::with_capacity(input.len()); + let mut cursor = 0; + while let Some(relative_start) = lower[cursor..].find(marker) { + let start = cursor + relative_start; + let value_start = start + marker.len(); + output.push_str(&input[cursor..value_start]); + output.push_str("[REDACTED]"); + + let mut end = input.len(); + for (offset, character) in input[value_start..].char_indices() { + if character.is_whitespace() + || matches!(character, '"' | '\'' | '&' | ',' | ';' | '}' | ']') + { + end = value_start + offset; + break; + } + } + cursor = end; + } + output.push_str(&input[cursor..]); + output +} + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); +} + +const TEAMS_FILE_DOWNLOAD_INFO_TYPE: &str = "application/vnd.microsoft.teams.file.download.info"; +const TEAMS_ATTACHMENT_MAX_REDIRECTS: usize = 4; +const TEAMS_FILE_HOST_SUFFIXES: &[&str] = &[ + "api.asm.skype.com", + "files.teams.microsoft.com", + "sharepoint.com", + "sharepointonline.com", + "1drv.com", + "onedrive.com", + "blob.core.windows.net", +]; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TeamsFileDownloadInfo { + download_url: String, + #[serde(default)] + file_size: Option, +} + +#[derive(Default)] +struct PreparedTeamsAttachments { + metadata: Vec, + sources: HashMap, +} + +struct AttachmentFailure { + category: &'static str, + detail: &'static str, + bytes_read: u64, +} + +impl AttachmentFailure { + fn new(category: &'static str, detail: &'static str) -> Self { + Self { + category, + detail, + bytes_read: 0, + } + } + + fn with_bytes_read(mut self, bytes_read: u64) -> Self { + self.bytes_read = bytes_read; + self + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AttachmentMaterializationError { + code: &'static str, + message: &'static str, +} + +impl AttachmentMaterializationError { + pub fn code(&self) -> &'static str { + self.code + } + + pub fn message(&self) -> &'static str { + self.message + } +} + +impl std::fmt::Display for AttachmentMaterializationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.message) + } +} + +impl std::error::Error for AttachmentMaterializationError {} + +fn sanitize_attachment_filename(value: Option<&str>, fallback: &str) -> String { + let mut sanitized: String = value + .unwrap_or_default() + .chars() + .filter_map(|character| match character { + '/' | '\\' => Some('_'), + character if character.is_control() => None, + character => Some(character), + }) + .take(TEAMS_FILENAME_LIMIT) + .collect(); + sanitized = sanitized.trim().to_owned(); + if sanitized.is_empty() { + fallback.to_owned() + } else { + sanitized + } +} + +fn sanitized_declared_mime(value: &str) -> String { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.') + }) + .take(128) + .collect() +} + +fn image_mime_for_filename(filename: &str) -> Option<&'static str> { + let extension = filename.rsplit_once('.')?.1.to_ascii_lowercase(); + match extension.as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "bmp" => Some("image/bmp"), + _ => None, + } +} + +fn rejected_attachment( + attachment_type: &str, + filename: String, + mime_type: String, + size: u64, + category: &'static str, + detail: &'static str, +) -> Attachment { + Attachment { + attachment_type: attachment_type.into(), + filename, + mime_type, + reference: None, + data: String::new(), + size, + path: None, + status: Some(format!("{category}: {detail}")), + } +} + +fn parse_file_download_info(content: Option<&serde_json::Value>) -> Option { + match content? { + serde_json::Value::String(value) => serde_json::from_str(value).ok(), + value => serde_json::from_value(value.clone()).ok(), + } +} + +fn attachment_url_base( + raw_url: &str, + label: &str, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + let url = + reqwest::Url::parse(raw_url).map_err(|_| anyhow::anyhow!("{label} is not a valid URL"))?; + if !url.username().is_empty() || url.password().is_some() { + anyhow::bail!("{label} must not contain userinfo"); + } + if url.fragment().is_some() { + anyhow::bail!("{label} must not contain a fragment"); + } + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("{label} is missing a host"))?; + if allow_non_public_endpoints { + if !matches!(url.scheme(), "http" | "https") { + anyhow::bail!("{label} must use HTTP or HTTPS in tests"); + } + return Ok(url); + } + if url.scheme() != "https" { + anyhow::bail!("{label} must use HTTPS"); + } + if host.parse::().is_ok() { + anyhow::bail!("{label} must not use an IP literal"); + } + if url.port_or_known_default() != Some(443) { + anyhow::bail!("{label} must use HTTPS port 443"); + } + Ok(url) +} + +fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool { + left.scheme() == right.scheme() + && left + .host_str() + .zip(right.host_str()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + && left.port_or_known_default() == right.port_or_known_default() +} + +fn validate_inline_attachment_url( + raw_url: &str, + service_origin: &reqwest::Url, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + let url = attachment_url_base( + raw_url, + "Teams inline attachment URL", + allow_non_public_endpoints, + )?; + if !same_origin(&url, service_origin) { + anyhow::bail!("Teams inline attachment URL must match the Connector origin"); + } + Ok(url) +} + +fn is_allowed_file_host(host: &str) -> bool { + TEAMS_FILE_HOST_SUFFIXES.iter().any(|suffix| { + host.eq_ignore_ascii_case(suffix) + || host.to_ascii_lowercase().ends_with(&format!(".{suffix}")) + }) +} + +fn validate_file_attachment_url( + raw_url: &str, + allow_non_public_endpoints: bool, +) -> anyhow::Result { + let url = attachment_url_base( + raw_url, + "Teams file attachment URL", + allow_non_public_endpoints, + )?; + if allow_non_public_endpoints { + return Ok(url); + } + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("Teams file attachment URL is missing a host"))?; + if !is_allowed_file_host(host) { + anyhow::bail!("Teams file attachment host is not in the public-cloud profile"); + } + Ok(url) +} + +fn prepare_attachment_metadata( + teams: &TeamsAdapter, + activity: &Activity, + service_origin: &reqwest::Url, + conversation_type: &str, +) -> PreparedTeamsAttachments { + let mut prepared = PreparedTeamsAttachments::default(); + let explicit_personal_scope = activity + .conversation + .as_ref() + .and_then(|conversation| conversation.conversation_type.as_deref()) + .filter(|value| !value.trim().is_empty()) + .is_some_and(|value| canonical_conversation_type(value) == "personal"); + for attachment in activity + .attachments + .iter() + .take(TEAMS_ATTACHMENT_METADATA_LIMIT) + { + let declared_mime = sanitized_declared_mime(&attachment.content_type); + let filename = sanitize_attachment_filename(attachment.name.as_deref(), "attachment"); + if declared_mime.starts_with("image/") { + let Some(content_url) = attachment + .content_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + prepared.metadata.push(rejected_attachment( + "image", + filename, + declared_mime, + 0, + "invalid content", + "inline image has no content URL", + )); + continue; + }; + let url = match validate_inline_attachment_url( + content_url, + service_origin, + teams.allow_non_public_endpoints, + ) { + Ok(url) => url, + Err(_) => { + prepared.metadata.push(rejected_attachment( + "image", + filename, + declared_mime, + 0, + "security rejected", + "inline image URL is outside the Connector origin", + )); + continue; + } + }; + let reference = format!("att_{}", uuid::Uuid::new_v4()); + prepared.sources.insert( + reference.clone(), + TeamsAttachmentSource { + kind: TeamsAttachmentSourceKind::InlineImage, + url, + service_origin: service_origin.clone(), + attachment_type: "image".into(), + filename: filename.clone(), + mime_type: declared_mime.clone(), + max_bytes: TEAMS_IMAGE_DOWNLOAD_LIMIT, + }, + ); + prepared.metadata.push(Attachment { + attachment_type: "image".into(), + filename, + mime_type: declared_mime, + reference: Some(reference), + data: String::new(), + size: 0, + path: None, + status: None, + }); + continue; + } + + if declared_mime == TEAMS_FILE_DOWNLOAD_INFO_TYPE { + let Some(info) = parse_file_download_info(attachment.content.as_ref()) else { + prepared.metadata.push(rejected_attachment( + "file", + filename, + declared_mime, + 0, + "invalid content", + "file download metadata is malformed", + )); + continue; + }; + let declared_size = info.file_size.unwrap_or(0); + if conversation_type != "personal" || !explicit_personal_scope { + prepared.metadata.push(rejected_attachment( + "file", + filename, + declared_mime, + declared_size, + "unsupported format", + "Teams file download is Personal-only", + )); + continue; + } + let (kind, attachment_type, normalized_mime, max_bytes) = + if let Some(image_mime) = image_mime_for_filename(&filename) { + ( + TeamsAttachmentSourceKind::PersonalFileImage, + "image", + image_mime, + TEAMS_IMAGE_DOWNLOAD_LIMIT, + ) + } else if crate::media::is_text_extension(&filename) { + ( + TeamsAttachmentSourceKind::PersonalTextFile, + "text_file", + "text/plain; charset=utf-8", + TEAMS_TEXT_DOWNLOAD_LIMIT, + ) + } else { + prepared.metadata.push(rejected_attachment( + "file", + filename, + declared_mime, + declared_size, + "unsupported format", + "file extension is not supported", + )); + continue; + }; + if declared_size > max_bytes { + prepared.metadata.push(rejected_attachment( + attachment_type, + filename, + normalized_mime.into(), + declared_size, + "size exceeded", + "declared file size exceeds the limit", + )); + continue; + } + let url = match validate_file_attachment_url( + &info.download_url, + teams.allow_non_public_endpoints, + ) { + Ok(url) => url, + Err(_) => { + prepared.metadata.push(rejected_attachment( + attachment_type, + filename, + normalized_mime.into(), + declared_size, + "security rejected", + "file URL is outside the public-cloud profile", + )); + continue; + } + }; + let reference = format!("att_{}", uuid::Uuid::new_v4()); + prepared.sources.insert( + reference.clone(), + TeamsAttachmentSource { + kind, + url, + service_origin: service_origin.clone(), + attachment_type: attachment_type.into(), + filename: filename.clone(), + mime_type: normalized_mime.into(), + max_bytes, + }, + ); + prepared.metadata.push(Attachment { + attachment_type: attachment_type.into(), + filename, + mime_type: normalized_mime.into(), + reference: Some(reference), + data: String::new(), + size: declared_size, + path: None, + status: None, + }); + continue; + } + + if declared_mime.starts_with("application/vnd.microsoft.card.") { + continue; + } + if attachment.content_url.is_some() || attachment.name.is_some() { + prepared.metadata.push(rejected_attachment( + "file", + filename, + declared_mime, + 0, + "unsupported format", + "attachment type is not supported", + )); + } + } + prepared +} + +fn materialization_protocol_error(error: AttachmentLookupError) -> AttachmentMaterializationError { + match error { + AttachmentLookupError::RouteNotFound => AttachmentMaterializationError { + code: "attachment_route_not_found", + message: "attachment route is unavailable", + }, + AttachmentLookupError::ConversationMismatch => AttachmentMaterializationError { + code: "attachment_scope_mismatch", + message: "attachment conversation does not match its route", + }, + AttachmentLookupError::ReferenceNotFound => AttachmentMaterializationError { + code: "attachment_reference_not_found", + message: "attachment reference is unavailable", + }, + AttachmentLookupError::AggregateLimitExceeded => AttachmentMaterializationError { + code: "attachment_budget_exceeded", + message: "attachment event budget is exhausted", + }, + } +} + +impl TeamsAdapter { + async fn download_attachment_bytes( + &self, + source: &TeamsAttachmentSource, + max_bytes: u64, + ) -> Result, AttachmentFailure> { + let bearer = if source.kind == TeamsAttachmentSourceKind::InlineImage { + Some(self.get_token().await.map_err(|_| { + AttachmentFailure::new("download failed", "Bot token is unavailable") + })?) + } else { + None + }; + let mut url = source.url.clone(); + let mut redirects = 0usize; + loop { + let mut request = self.attachment_client.get(url.clone()); + if let Some(token) = bearer.as_deref() { + request = request.bearer_auth(token); + } + let mut response = request.send().await.map_err(|_| { + AttachmentFailure::new("download failed", "attachment request failed") + })?; + if response.status().is_redirection() { + if redirects >= TEAMS_ATTACHMENT_MAX_REDIRECTS { + return Err(AttachmentFailure::new( + "security rejected", + "attachment redirect limit exceeded", + )); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + AttachmentFailure::new( + "download failed", + "attachment redirect has no valid location", + ) + })?; + let candidate = url.join(location).map_err(|_| { + AttachmentFailure::new("security rejected", "attachment redirect is invalid") + })?; + url = match source.kind { + TeamsAttachmentSourceKind::InlineImage => validate_inline_attachment_url( + candidate.as_str(), + &source.service_origin, + self.allow_non_public_endpoints, + ), + TeamsAttachmentSourceKind::PersonalFileImage + | TeamsAttachmentSourceKind::PersonalTextFile => validate_file_attachment_url( + candidate.as_str(), + self.allow_non_public_endpoints, + ), + } + .map_err(|_| { + AttachmentFailure::new( + "security rejected", + "attachment redirect is outside the allowed origin profile", + ) + })?; + redirects += 1; + continue; + } + if !response.status().is_success() { + return Err(AttachmentFailure::new( + "download failed", + "Microsoft attachment response was not successful", + )); + } + if response + .content_length() + .is_some_and(|size| size > max_bytes) + { + return Err(AttachmentFailure::new( + "size exceeded", + "attachment Content-Length exceeds the limit", + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| { + AttachmentFailure::new("download failed", "attachment body read failed") + .with_bytes_read(bytes.len() as u64) + })? { + let next_len = bytes.len().saturating_add(chunk.len()); + if next_len as u64 > max_bytes { + return Err(AttachmentFailure::new( + "size exceeded", + "attachment body exceeds the limit", + ) + .with_bytes_read(max_bytes)); + } + bytes.extend_from_slice(&chunk); + } + return Ok(bytes); + } + } + + pub async fn materialize_attachment( + &self, + event_id: &str, + conversation_id: &str, + reference: &str, + ) -> Result { + if !self.inbound_attachments_enabled() { + return Err(AttachmentMaterializationError { + code: "attachment_materialization_disabled", + message: "attachment materialization is disabled", + }); + } + let claim = self + .ingress + .lock() + .await + .claim_attachment(event_id, conversation_id, reference, Instant::now()) + .map_err(materialization_protocol_error)?; + let download = self + .download_attachment_bytes(&claim.source, claim.reserved_bytes) + .await; + let raw_bytes = download + .as_ref() + .map(|bytes| bytes.len() as u64) + .unwrap_or_else(|failure| failure.bytes_read); + self.ingress + .lock() + .await + .finish_attachment(event_id, claim.reserved_bytes, raw_bytes); + + let bytes = match download { + Ok(bytes) => bytes, + Err(failure) => { + return Ok(rejected_attachment( + &claim.source.attachment_type, + claim.source.filename, + claim.source.mime_type, + raw_bytes, + failure.category, + failure.detail, + )); + } + }; + let normalized = match claim.source.kind { + TeamsAttachmentSourceKind::InlineImage + | TeamsAttachmentSourceKind::PersonalFileImage => { + match tokio::task::spawn_blocking(move || { + crate::media::resize_and_compress(&bytes) + }) + .await + { + Ok(result) => result.map_err(|_| { + AttachmentFailure::new( + "processing failed", + "image decoding or normalization failed", + ) + }), + Err(_) => Err(AttachmentFailure::new( + "processing failed", + "image normalization task failed", + )), + } + } + TeamsAttachmentSourceKind::PersonalTextFile => { + if std::str::from_utf8(&bytes).is_err() { + Err(AttachmentFailure::new( + "invalid content", + "text attachment is not valid UTF-8", + )) + } else { + Ok((bytes, "text/plain; charset=utf-8".into())) + } + } + }; + let (normalized_bytes, mime_type) = match normalized { + Ok(normalized) => normalized, + Err(failure) => { + return Ok(rejected_attachment( + &claim.source.attachment_type, + claim.source.filename, + claim.source.mime_type, + raw_bytes, + failure.category, + failure.detail, + )); + } + }; + let encoded = base64::engine::general_purpose::STANDARD.encode(&normalized_bytes); + if encoded.len().saturating_add(4096) > TEAMS_MATERIALIZED_FRAME_LIMIT { + return Ok(rejected_attachment( + &claim.source.attachment_type, + claim.source.filename, + mime_type, + raw_bytes, + "size exceeded", + "normalized attachment exceeds the internal frame limit", + )); + } + Ok(Attachment { + attachment_type: claim.source.attachment_type, + filename: claim.source.filename, + mime_type, + reference: None, + data: encoded, + size: normalized_bytes.len() as u64, + path: None, + status: None, + }) + } +} + +// --- Webhook handler --- + +/// Max webhook body size: 256 KB. Real Teams activities are a few KB; the +/// activity is parsed *before* JWT auth (Bot Framework requires serviceUrl / +/// channelId from the body to validate the token), so this caps the +/// unauthenticated parse attack surface. Mirrors the feishu adapter's limit. +const WEBHOOK_BODY_LIMIT: usize = 256 * 1024; + +pub async fn webhook( + State(state): State>, + headers: HeaderMap, + body: String, +) -> StatusCode { + let teams = match &state.teams { + Some(t) => t, + None => return StatusCode::NOT_FOUND, + }; + + // Defense-in-depth: bound the pre-auth body size (axum's default limit is 2 MB). + if body.len() > WEBHOOK_BODY_LIMIT { + warn!(size = body.len(), "teams webhook body too large"); + return StatusCode::PAYLOAD_TOO_LARGE; + } + + // Extract auth header early (before parsing activity) + let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(h) => h.to_string(), + None => { + warn!("teams webhook: missing authorization header"); + return StatusCode::UNAUTHORIZED; + } + }; + + // Parse activity first (needed for JWT serviceUrl + endorsements validation). + // + // SECURITY NOTE (OX untrusted-deserialization finding — false positive): + // `Activity` is a strict, derive-only DTO (String / Option<_> / nested + // structs) with no custom Deserialize, no side-effectful Drop, and no enum + // variant dispatch. serde_json's data model cannot instantiate arbitrary + // types (unlike bincode/serde_yaml/rmp-serde), so object-injection / RCE + // does not apply. The recommended "strict DTO + validate after" pattern is + // already in place: JWT, activity-type, and tenant-allowlist checks below. + // DoS is bounded by serde_json's recursion limit (128) and the body cap above. + let activity: Activity = match serde_json::from_str(&body) { + Ok(a) => a, + Err(e) => { + warn!(error = %e, "teams: invalid activity JSON"); + return StatusCode::BAD_REQUEST; + } + }; + + if activity.activity_type == "message" { + if let Some(field) = activity.missing_required_message_field() { + warn!(field, "teams: message missing required field"); + return StatusCode::BAD_REQUEST; + } + } + + // JWT validation (with activity context for serviceUrl + channelId checks) + if let Err(e) = teams.validate_jwt(&auth_header, &activity).await { + warn!(error = %e, "teams JWT validation failed"); + return StatusCode::UNAUTHORIZED; + } + + if activity.activity_type == "installationUpdate" { + if !teams.check_tenant(&activity) { + warn!("teams: installation update tenant is not allowed"); + return StatusCode::FORBIDDEN; + } + return handle_installation_update(teams, &activity).await; + } + + // Preserve the existing ignore behavior for other non-message activities. + if activity.activity_type != "message" { + debug!(activity_type = %activity.activity_type, "teams: ignoring non-message activity"); + return StatusCode::OK; + } + + // Tenant check + if !teams.check_tenant(&activity) { + let tid = activity.resolved_tenant_id().unwrap_or("unknown"); + warn!(tenant = tid, "teams: tenant not in allowlist"); + return StatusCode::FORBIDDEN; + } + + accept_message_activity(state, activity).await +} + +async fn handle_installation_update(teams: &TeamsAdapter, activity: &Activity) -> StatusCode { + let action = activity.action.as_deref().unwrap_or_default(); + if !action.eq_ignore_ascii_case("remove") && !action.eq_ignore_ascii_case("remove-upgrade") { + return StatusCode::OK; + } + let Some(tenant_id) = activity + .resolved_tenant_id() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: installation removal is missing tenant identity"); + return StatusCode::BAD_REQUEST; + }; + let Some(bot_framework_channel_id) = activity + .channel_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: installation removal is missing Bot Framework channel identity"); + return StatusCode::BAD_REQUEST; + }; + let Some(conversation_id) = activity + .conversation + .as_ref() + .and_then(|conversation| conversation.id.as_deref()) + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: installation removal is missing conversation identity"); + return StatusCode::BAD_REQUEST; + }; + + match teams + .revoke_installed_conversation( + tenant_id, + bot_framework_channel_id, + conversation_id, + activity + .channel_data + .as_ref() + .and_then(|data| data.team.as_ref()) + .and_then(|team| team.id.as_deref()) + .filter(|value| !value.trim().is_empty()), + ) + .await + { + Ok(changed) => { + info!( + changed, + "teams conversation installation revocation processed" + ); + StatusCode::OK + } + Err(error) => { + error!(error = %error, "teams conversation installation revocation failed"); + StatusCode::SERVICE_UNAVAILABLE + } + } +} + +enum LocalPublishOutcome { + Accepted { receiver_count: usize }, + AcceptedDuplicate, + PublishingDuplicate(tokio::sync::watch::Receiver), + AtCapacity, + NoConsumer, + StateCommitFailed, +} + +/// Publish one already-authenticated and tenant-authorized Teams message. +/// +/// Keeping this post-auth path separate makes the local enqueue, route, and +/// dedupe contract testable without weakening JWT validation in `webhook`. +async fn accept_message_activity(state: Arc, activity: Activity) -> StatusCode { + let Some(teams) = state.teams.as_ref() else { + return StatusCode::NOT_FOUND; + }; + if let Some(field) = activity.missing_required_message_field() { + warn!(field, "teams: message missing required field"); + return StatusCode::BAD_REQUEST; + } + + let text = activity.text.as_deref().unwrap_or_default().trim(); + if text.is_empty() && (!teams.inbound_attachments_enabled() || activity.attachments.is_empty()) + { + return StatusCode::OK; + } + let Some(tenant_id) = activity + .resolved_tenant_id() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required tenant id"); + return StatusCode::BAD_REQUEST; + }; + let Some(bot_framework_channel_id) = activity + .channel_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required Bot Framework channel id"); + return StatusCode::BAD_REQUEST; + }; + let Some(conversation_id) = activity + .conversation + .as_ref() + .and_then(|conversation| conversation.id.as_deref()) + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required conversation id"); + return StatusCode::BAD_REQUEST; + }; + let Some(activity_id) = activity + .id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required activity id"); + return StatusCode::BAD_REQUEST; + }; + let Some(sender_id) = activity + .from + .as_ref() + .and_then(|sender| sender.id.as_deref()) + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required sender id"); + return StatusCode::BAD_REQUEST; + }; + let Some(service_url) = activity + .service_url + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: message missing required service URL"); + return StatusCode::BAD_REQUEST; + }; + + // JWT validation binds this value to Microsoft; the public-cloud policy + // additionally prevents credential-bearing SSRF before local persistence. + let validated_service_url = match teams.validate_service_url(service_url) { + Ok(url) => url, + Err(error) => { + warn!(reason = %error, "teams: activity has unsafe service_url"); + return StatusCode::BAD_REQUEST; + } + }; + let conversation_type = canonical_conversation_type( + activity + .conversation + .as_ref() + .and_then(|conversation| conversation.conversation_type.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("personal"), + ); + let prepared_attachments = if teams.inbound_attachments_enabled() { + prepare_attachment_metadata(teams, &activity, &validated_service_url, &conversation_type) + } else { + PreparedTeamsAttachments::default() + }; + if text.is_empty() && prepared_attachments.metadata.is_empty() { + return StatusCode::OK; + } + let sender_name = activity + .from + .as_ref() + .and_then(|sender| sender.name.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("Unknown"); + let scope = activity.gateway_scope(tenant_id, conversation_id, &conversation_type); + let route_team_id = scope.team_id.clone(); + let route_channel_id = scope.channel_id.clone(); + let recipient = activity.recipient_info(); + let (mentions, mention_entities) = activity.mention_info(); + + let mut event = GatewayEvent::new( + "teams", + ChannelInfo { + id: conversation_id.to_owned(), + channel_type: conversation_type.clone(), + thread_id: None, + }, + SenderInfo { + id: sender_id.to_owned(), + name: sender_name.to_owned(), + display_name: sender_name.to_owned(), + is_bot: false, + }, + text, + activity_id, + mentions, + ); + event.scope = Some(scope); + event.recipient = recipient; + event.mention_entities = mention_entities; + event.content.attachments = prepared_attachments.metadata; + let event_id = event.event_id.clone(); + let route_key = TeamsRouteKey::new( + teams.config.app_id.clone(), + tenant_id, + conversation_id, + activity_id, + ); + let now = Instant::now(); + let route = TeamsIngressRoute { + key: route_key.clone(), + event_id: event_id.clone(), + tenant_id: tenant_id.to_owned(), + bot_framework_channel_id: bot_framework_channel_id.to_owned(), + conversation_id: conversation_id.to_owned(), + conversation_type: conversation_type.clone(), + inbound_activity_id: activity_id.to_owned(), + reply_chain_root_id: activity.reply_to_id.clone(), + service_url: validated_service_url.clone(), + team_id: route_team_id, + channel_id: route_channel_id, + attachment_sources: prepared_attachments.sources, + attachment_materialized_bytes: 0, + created_at: now, + }; + let json = match serde_json::to_string(&event) { + Ok(json) => json, + Err(serialization_error) => { + error!(error = %serialization_error, "teams: failed to serialize gateway event"); + return StatusCode::INTERNAL_SERVER_ERROR; + } + }; + + // Reserve, commit the route, and enqueue while holding one state lock. No + // await point exists after Publishing begins, so cancellation cannot leave + // an owner stranded between local enqueue and Accepted/Failed resolution. + let publish_outcome = { + let mut ingress = teams.ingress.lock().await; + match ingress.reserve(route_key.clone(), event_id.clone(), now) { + PublishReservation::AcceptedDuplicate => LocalPublishOutcome::AcceptedDuplicate, + PublishReservation::PublishingDuplicate(completion) => { + LocalPublishOutcome::PublishingDuplicate(completion) + } + PublishReservation::AtCapacity => LocalPublishOutcome::AtCapacity, + PublishReservation::Owner => { + if !ingress.accept(&route_key, &event_id, route, Instant::now()) { + ingress.fail(&route_key, &event_id); + LocalPublishOutcome::StateCommitFailed + } else { + match state.event_tx.send(json) { + Ok(receiver_count) => LocalPublishOutcome::Accepted { receiver_count }, + Err(_) => { + ingress.fail(&route_key, &event_id); + LocalPublishOutcome::NoConsumer + } + } + } + } + } + }; + + match publish_outcome { + LocalPublishOutcome::Accepted { receiver_count } => { + info!( + conversation = conversation_id, + sender = sender_name, + tenant = tenant_id, + service_host = validated_service_url.host_str().unwrap_or("unknown"), + receiver_count, + "teams → gateway" + ); + StatusCode::OK + } + LocalPublishOutcome::AcceptedDuplicate => { + debug!("teams: accepted duplicate activity suppressed"); + StatusCode::OK + } + LocalPublishOutcome::PublishingDuplicate(completion) => { + match wait_for_publish(completion).await { + PublishState::Accepted => StatusCode::OK, + PublishState::Publishing | PublishState::Failed => StatusCode::SERVICE_UNAVAILABLE, + } + } + LocalPublishOutcome::AtCapacity => { + warn!("teams: ingress dedupe cache is saturated by active publications"); + StatusCode::SERVICE_UNAVAILABLE + } + LocalPublishOutcome::NoConsumer => { + warn!("teams: no event consumer accepted the activity; returning retryable failure"); + StatusCode::SERVICE_UNAVAILABLE + } + LocalPublishOutcome::StateCommitFailed => { + error!("teams: failed to commit ingress state before local enqueue"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + +// --- Reply handler --- + +fn rejected_outcome(code: &str, message: impl Into) -> WriteOutcome { + WriteOutcome::Rejected { + code: code.into(), + message: message.into(), + retry_after_ms: None, + } +} + +fn validate_persistent_envelope( + reply: &GatewayReply, + target: &PersistentConversationTarget, +) -> Result<(), WriteOutcome> { + if reply.platform != "teams" + || reply.channel.id != target.conversation_id + || reply.channel.thread_id.is_some() + || !reply.reply_to.is_empty() + { + return Err(rejected_outcome( + "persistent_target_mismatch", + "Teams persistent conversation target is invalid", + )); + } + Ok(()) +} + +fn sanitize_persistent_outcome(outcome: WriteOutcome) -> WriteOutcome { + match outcome { + delivered @ WriteOutcome::Delivered { .. } => delivered, + WriteOutcome::Rejected { + code, + retry_after_ms, + .. + } => WriteOutcome::Rejected { + code, + message: "Teams persistent conversation write was rejected".into(), + retry_after_ms, + }, + WriteOutcome::Unknown { code, .. } => WriteOutcome::Unknown { + code, + message: "Teams persistent conversation write outcome is unknown".into(), + }, + } +} + +async fn persistent_target_is_owned( + teams: &TeamsAdapter, + key: &TeamsConversationKey, + activity_id: &str, +) -> bool { + teams + .ingress + .lock() + .await + .owned_route_for_exact_target( + &key.app_id, + &key.tenant_id, + &key.conversation_id, + activity_id, + Instant::now(), + ) + .is_ok() +} + +async fn handle_persistent_send(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { + let target = reply + .persistent_conversation + .as_ref() + .expect("persistent send dispatch requires a target"); + if let Err(outcome) = validate_persistent_envelope(reply, target) { + return outcome; + } + let (_, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let _write_guard = teams.lock_conversation(&route).await; + let (key, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let quote_activity_id = match reply + .quote_message_id + .as_deref() + .filter(|activity_id| !activity_id.trim().is_empty()) + { + Some(activity_id) if persistent_target_is_owned(teams, &key, activity_id).await => { + Some(activity_id) + } + Some(_) => { + debug!( + operation = "persistent_send", + "teams persistent quote target is not bot-owned; sending without quote" + ); + None + } + None => None, + }; + + info!( + operation = "persistent_send", + "gateway → teams persistent write" + ); + let outcome = teams + .send_activity_outcome( + route.service_url.as_str(), + &route.conversation_id, + &reply.content.text, + quote_activity_id, + ) + .await; + if let WriteOutcome::Delivered { + message_id: Some(activity_id), + } = &outcome + { + teams + .ingress + .lock() + .await + .record_owned(&route, activity_id, Instant::now()); + debug!( + operation = "persistent_send", + "teams activity ownership recorded" + ); + } + teams.reconcile_persistent_write(&key, &outcome).await; + sanitize_persistent_outcome(outcome) +} + +async fn handle_persistent_mutation( + reply: &GatewayReply, + teams: &TeamsAdapter, + command: &str, +) -> WriteOutcome { + let target = reply + .persistent_conversation + .as_ref() + .expect("persistent mutation dispatch requires a target"); + if let Err(outcome) = validate_persistent_envelope(reply, target) { + return outcome; + } + let Some(activity_id) = reply + .target_message_id + .as_deref() + .filter(|activity_id| !activity_id.trim().is_empty()) + else { + return rejected_outcome( + "invalid_target", + "Teams persistent mutation is missing a target activity", + ); + }; + let (_, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let _write_guard = teams.lock_conversation(&route).await; + let (key, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + if !persistent_target_is_owned(teams, &key, activity_id).await { + return rejected_outcome( + "message_not_owned", + "Teams target is not a bot-owned activity in this process", + ); + } + + info!(operation = command, "gateway → teams persistent mutation"); + let outcome = match command { + "edit_message" => { + teams + .update_activity_outcome_without_retry( + route.service_url.as_str(), + &route.conversation_id, + activity_id, + &reply.content.text, + ) + .await + } + "delete_message" => { + teams + .delete_activity_outcome_without_retry( + route.service_url.as_str(), + &route.conversation_id, + activity_id, + ) + .await + } + _ => unreachable!("persistent mutation dispatch is command-checked"), + }; + if command == "delete_message" && matches!(outcome, WriteOutcome::Delivered { .. }) { + teams.ingress.lock().await.remove_owned(&route, activity_id); + } + teams.reconcile_persistent_write(&key, &outcome).await; + sanitize_persistent_outcome(outcome) +} + +async fn handle_persistent_reaction( + reply: &GatewayReply, + teams: &TeamsAdapter, + command: &str, +) -> WriteOutcome { + let target = reply + .persistent_conversation + .as_ref() + .expect("persistent reaction dispatch requires a target"); + if let Err(outcome) = validate_persistent_envelope(reply, target) { + return outcome; + } + let Some(activity_id) = reply + .target_message_id + .as_deref() + .filter(|activity_id| !activity_id.trim().is_empty()) + else { + return rejected_outcome( + "invalid_target", + "Teams persistent reaction is missing a target activity", + ); + }; + let (_, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + if !teams.reactions_enabled() { + return WriteOutcome::Delivered { message_id: None }; + } + let _write_guard = teams.lock_conversation(&route).await; + let (key, route) = match teams.resolve_persistent_route(target).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + if !persistent_target_is_owned(teams, &key, activity_id).await { + return rejected_outcome( + "reaction_target_not_known", + "Teams reaction target is not bot-owned in this process", + ); + } + + info!(operation = command, "gateway → teams persistent reaction"); + let outcome = match command { + "add_reaction" => { + teams + .add_reaction_outcome_without_retry( + route.service_url.as_str(), + &route.conversation_id, + activity_id, + &reply.content.text, + ) + .await + } + "remove_reaction" => { + teams + .remove_reaction_outcome_without_retry( + route.service_url.as_str(), + &route.conversation_id, + activity_id, + &reply.content.text, + ) + .await + } + _ => unreachable!("persistent reaction dispatch is command-checked"), + }; + teams.reconcile_persistent_write(&key, &outcome).await; + sanitize_persistent_outcome(outcome) +} + +async fn resolve_send_route( + teams: &TeamsAdapter, + reply: &GatewayReply, +) -> Result<(TeamsIngressRoute, Option), WriteOutcome> { + let result = teams.ingress.lock().await.route_for_reply( + &reply.reply_to, + &reply.channel.id, + reply.quote_message_id.as_deref(), + Instant::now(), + ); + match result { + Ok(route) => Ok(route), + Err(RouteLookupError::NotFound) => Err(rejected_outcome( + "route_not_found", + "Teams ingress route is missing or expired", + )), + Err(RouteLookupError::ConversationMismatch) => Err(rejected_outcome( + "route_mismatch", + "Teams reply conversation does not match its ingress route", + )), + } +} + +async fn handle_send_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { + let (route, _) = match resolve_send_route(teams, reply).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let _write_guard = teams.lock_conversation(&route).await; + let (route, quote_activity_id) = match resolve_send_route(teams, reply).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + + if reply.quote_message_id.is_some() && quote_activity_id.is_none() { + warn!( + conversation = %route.conversation_id, + "teams: quote target is not known in the ingress route scope; sending without quote" + ); + } + + info!(conversation = %route.conversation_id, "gateway → teams"); + let outcome = teams + .send_activity_outcome( + route.service_url.as_str(), + &route.conversation_id, + &reply.content.text, + quote_activity_id.as_deref(), + ) + .await; + if let WriteOutcome::Delivered { + message_id: Some(activity_id), + } = &outcome + { + teams + .ingress + .lock() + .await + .record_owned(&route, activity_id, Instant::now()); + debug!(activity_id, "teams activity sent and ownership recorded"); + } + outcome +} + +fn command_target(reply: &GatewayReply) -> Result<(&str, Option<&str>), WriteOutcome> { + match reply.target_message_id.as_deref() { + Some(target) if target.trim().is_empty() => Err(rejected_outcome( + "invalid_target", + "Teams command target must not be empty", + )), + Some(target) => Ok((target, Some(reply.reply_to.as_str()))), + None if reply.reply_to.trim().is_empty() => Err(rejected_outcome( + "invalid_target", + "Teams command is missing a target message ID", + )), + None => Ok((reply.reply_to.as_str(), None)), + } +} + +async fn resolve_owned_route( + teams: &TeamsAdapter, + reply: &GatewayReply, + target_activity_id: &str, + origin_event_id: Option<&str>, +) -> Result { + let result = teams.ingress.lock().await.owned_route_for_target( + &teams.config.app_id, + origin_event_id, + &reply.channel.id, + target_activity_id, + Instant::now(), + ); + match result { + Ok(route) => Ok(route), + Err(OwnershipLookupError::NotOwned) => Err(rejected_outcome( + "message_not_owned", + "Teams target is not a bot-owned activity in this process", + )), + Err(OwnershipLookupError::OriginRouteNotFound) => Err(rejected_outcome( + "target_origin_not_found", + "Teams command origin route is missing or expired", + )), + Err(OwnershipLookupError::ConversationMismatch) => Err(rejected_outcome( + "target_scope_mismatch", + "Teams command conversation does not match its origin route", + )), + Err(OwnershipLookupError::AmbiguousScope) => Err(rejected_outcome( + "target_scope_ambiguous", + "Teams legacy command target is ambiguous across tenant scope", + )), + } +} + +async fn handle_owned_mutation( + reply: &GatewayReply, + teams: &TeamsAdapter, + command: &str, +) -> WriteOutcome { + let (target_activity_id, origin_event_id) = match command_target(reply) { + Ok(target) => target, + Err(outcome) => return outcome, + }; + let route = match resolve_owned_route(teams, reply, target_activity_id, origin_event_id).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let _write_guard = teams.lock_conversation(&route).await; + let route = match resolve_owned_route(teams, reply, target_activity_id, origin_event_id).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + + info!(conversation = %route.conversation_id, command, "gateway → teams mutation"); + let outcome = match command { + "edit_message" => { + teams + .update_activity_outcome( + route.service_url.as_str(), + &route.conversation_id, + target_activity_id, + &reply.content.text, + ) + .await + } + "delete_message" => { + teams + .delete_activity_outcome( + route.service_url.as_str(), + &route.conversation_id, + target_activity_id, + ) + .await + } + _ => unreachable!("owned mutation dispatch is command-checked"), + }; + if command == "delete_message" && matches!(outcome, WriteOutcome::Delivered { .. }) { + teams + .ingress + .lock() + .await + .remove_owned(&route, target_activity_id); + } + outcome +} + +async fn resolve_reaction_route( + teams: &TeamsAdapter, + reply: &GatewayReply, + target_activity_id: &str, + origin_event_id: Option<&str>, +) -> Result { + let result = teams.ingress.lock().await.route_for_reaction_target( + &teams.config.app_id, + origin_event_id, + &reply.channel.id, + target_activity_id, + Instant::now(), + ); + match result { + Ok(route) => Ok(route), + Err(ReactionLookupError::TargetNotKnown) => Err(rejected_outcome( + "reaction_target_not_known", + "Teams reaction target is not authenticated in this process", + )), + Err(ReactionLookupError::OriginRouteNotFound) => Err(rejected_outcome( + "target_origin_not_found", + "Teams reaction origin route is missing or expired", + )), + Err(ReactionLookupError::ConversationMismatch) => Err(rejected_outcome( + "target_scope_mismatch", + "Teams reaction conversation does not match its origin route", + )), + Err(ReactionLookupError::AmbiguousScope) => Err(rejected_outcome( + "target_scope_ambiguous", + "Teams legacy reaction target is ambiguous across tenant scope", + )), + } +} + +async fn handle_reaction( + reply: &GatewayReply, + teams: &TeamsAdapter, + command: &str, +) -> WriteOutcome { + if !teams.reactions_enabled() { + debug!( + command, + "teams: reaction preview is disabled; ignoring command" + ); + return WriteOutcome::Delivered { message_id: None }; + } + + let (target_activity_id, origin_event_id) = match command_target(reply) { + Ok(target) => target, + Err(outcome) => return outcome, + }; + let route = + match resolve_reaction_route(teams, reply, target_activity_id, origin_event_id).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + let _write_guard = teams.lock_conversation(&route).await; + let route = + match resolve_reaction_route(teams, reply, target_activity_id, origin_event_id).await { + Ok(route) => route, + Err(outcome) => return outcome, + }; + + info!(conversation = %route.conversation_id, command, "gateway → teams reaction"); + match command { + "add_reaction" => { + teams + .add_reaction_outcome( + route.service_url.as_str(), + &route.conversation_id, + target_activity_id, + &reply.content.text, + ) + .await + } + "remove_reaction" => { + teams + .remove_reaction_outcome( + route.service_url.as_str(), + &route.conversation_id, + target_activity_id, + &reply.content.text, + ) + .await + } + _ => unreachable!("reaction dispatch is command-checked"), + } +} + +pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { + if reply.persistent_conversation.is_some() { + return match reply.command.as_deref() { + None => handle_persistent_send(reply, teams).await, + Some(command @ ("edit_message" | "delete_message")) => { + handle_persistent_mutation(reply, teams, command).await + } + Some(command @ ("add_reaction" | "remove_reaction")) => { + handle_persistent_reaction(reply, teams, command).await + } + Some(_) => rejected_outcome( + "persistent_command_rejected", + "command is unavailable for a Teams persistent conversation", + ), + }; + } + + match reply.command.as_deref() { + None => handle_send_reply(reply, teams).await, + Some("register_conversation") => { + teams + .register_conversation(&reply.reply_to, &reply.channel.id) + .await + } + Some(command @ ("edit_message" | "delete_message")) => { + handle_owned_mutation(reply, teams, command).await + } + Some(command @ ("add_reaction" | "remove_reaction")) => { + handle_reaction(reply, teams, command).await + } + Some(command) => rejected_outcome( + "unsupported_command", + format!("unsupported Teams command: {command}"), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapters::teams_registry::{RegistryCounts, TeamsConversationEntry}; + use wiremock::{ + matchers::{body_json, header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + // --- Bot Connector URL and error hardening --- + + #[test] + fn connector_url_encodes_segments_and_preserves_service_path() -> anyhow::Result<()> { + let url = connector_url( + "https://smba.trafficmanager.net/teams/", + "a/b?c", + Some("message id/%"), + false, + )?; + assert_eq!( + url.as_str(), + "https://smba.trafficmanager.net/teams/v3/conversations/a%2Fb%3Fc/activities/message%20id%2F%25" + ); + Ok(()) + } + + #[test] + fn reaction_url_and_default_status_emojis_use_teams_ids() -> anyhow::Result<()> { + let url = reaction_url( + "https://smba.trafficmanager.net/teams/", + "a/b?c", + "message id/%", + "1f440_eyes", + false, + )?; + assert_eq!( + url.as_str(), + "https://smba.trafficmanager.net/teams/v3/conversations/a%2Fb%3Fc/activities/message%20id%2F%25/reactions/1f440_eyes" + ); + for (emoji, expected) in [ + ("👀", "1f440_eyes"), + ("🤔", "think"), + ("🔥", "fire"), + ("👨‍💻", "mantechie"), + ("⚡", "26a1_highvoltagesign"), + ("🆗", "1f197_squaredok"), + ("🥱", "1f971_yawningface"), + ("😨", "fearful"), + ("😱", "screamingfear"), + ("🫡", "salute"), + ("✅", "2705_whiteheavycheckmark"), + ] { + assert_eq!(teams_reaction_type(emoji).as_deref(), Some(expected)); + } + assert_eq!( + teams_reaction_type("1f44b_wavinghand-tone4").as_deref(), + Some("1f44b_wavinghand-tone4") + ); + assert!(teams_reaction_type("not/a/reaction").is_none()); + Ok(()) + } + + #[test] + fn service_url_policy_accepts_only_public_teams_connector() { + assert!(validate_public_cloud_endpoint( + "https://smba.trafficmanager.net/teams/", + "Teams service URL", + TEAMS_PUBLIC_SERVICE_HOST, + false, + ) + .is_ok()); + + for rejected in [ + "http://smba.trafficmanager.net/teams/", + "https://user@smba.trafficmanager.net/teams/", + "https://127.0.0.1/teams/", + "https://[::1]/teams/", + "https://localhost/teams/", + "https://example.com/teams/", + "https://smba.trafficmanager.net.example.com/teams/", + "https://smba.trafficmanager.net:8443/teams/", + "https://smba.trafficmanager.net/teams/?target=other", + "https://smba.trafficmanager.net/teams/#fragment", + ] { + assert!( + validate_public_cloud_endpoint( + rejected, + "Teams service URL", + TEAMS_PUBLIC_SERVICE_HOST, + false, + ) + .is_err(), + "unsafe service URL should be rejected" + ); + } + } + + #[test] + fn connector_url_rejects_empty_and_dot_segment_ids() { + for conversation_id in ["", ".", ".."] { + assert!(connector_url( + "https://smba.trafficmanager.net/teams/", + conversation_id, + None, + false, + ) + .is_err()); + } + assert!(connector_url( + "https://smba.trafficmanager.net/teams/", + "conversation", + Some(".."), + false, + ) + .is_err()); + } + + #[test] + fn error_text_redacts_tokens_secrets_and_urls() { + let json = redact_sensitive_text( + r#"{"access_token":"top-secret","nested":{"client_secret":"also-secret"},"next":"https://sensitive.example/path"}"#, + &[], + ); + assert!(!json.contains("top-secret")); + assert!(!json.contains("also-secret")); + assert!(!json.contains("sensitive.example")); + assert!(json.contains("[REDACTED]")); + assert!(json.contains("[REDACTED_URL]")); + + let truncated_json = redact_sensitive_text(r#"{"access_token":"truncated-secret"#, &[]); + assert!(!truncated_json.contains("truncated-secret")); + + let plain = redact_sensitive_text( + "authorization failed: Bearer bearer-secret access_token=query-secret exact-secret https://private.example/path", + &["exact-secret"], + ); + assert!(!plain.contains("bearer-secret")); + assert!(!plain.contains("query-secret")); + assert!(!plain.contains("exact-secret")); + assert!(!plain.contains("private.example")); + } + + // --- check_tenant --- + + fn make_config(tenants: Vec<&str>) -> TeamsConfig { + TeamsConfig { + app_id: "test-app".into(), + app_secret: "test-secret".into(), + oauth_endpoint: "https://example.com/token".into(), + openid_metadata: "https://example.com/openid".into(), + allowed_tenants: tenants.into_iter().map(|s| s.to_string()).collect(), + dedupe_ttl_secs: DEFAULT_DEDUPE_TTL_SECS, + route_ttl_secs: DEFAULT_ROUTE_TTL_SECS, + max_route_entries: DEFAULT_MAX_ROUTE_ENTRIES, + reactions_enabled: false, + inbound_attachments: false, + conversation_registry_path: None, + conversation_registry_max_entries: DEFAULT_CONVERSATION_REGISTRY_MAX_ENTRIES, + conversation_registry_ttl_secs: DEFAULT_CONVERSATION_REGISTRY_TTL_SECS, + } + } + + fn registry_test_path(label: &str) -> (std::path::PathBuf, std::path::PathBuf) { + let root = std::fs::canonicalize(std::env::temp_dir()).expect("temp root"); + let directory = root.join(format!( + "openab-teams-adapter-registry-{label}-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir(&directory).expect("registry test directory"); + let path = directory.join("registry.json"); + (directory, path) + } + + fn make_http_test_config(server: &MockServer) -> TeamsConfig { + let mut config = make_config(vec![]); + config.oauth_endpoint = format!("{}/token", server.uri()); + config.openid_metadata = format!("{}/openid", server.uri()); + config + } + + fn make_test_state() -> Arc { + let (event_tx, _rx) = tokio::sync::broadcast::channel(16); + + Arc::new(crate::AppState { + teams: Some(TeamsAdapter::new(make_config(vec![]))), + ..crate::AppState::test_default(event_tx) + }) + } + + fn make_routable_state() -> ( + Arc, + tokio::sync::broadcast::Receiver, + ) { + let (event_tx, event_rx) = tokio::sync::broadcast::channel(16); + let state = Arc::new(crate::AppState { + teams: Some(TeamsAdapter::new(make_config(vec![]))), + ..crate::AppState::test_default(event_tx) + }); + (state, event_rx) + } + + fn make_reply(command: Option<&str>) -> GatewayReply { + GatewayReply { + attachment_ref: None, + persistent_conversation: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt-1".into(), + platform: "teams".into(), + channel: ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: Content { + content_type: "text".into(), + text: "reply text".into(), + attachments: vec![], + }, + command: command.map(str::to_owned), + request_id: None, + quote_message_id: None, + target_message_id: None, + } + } + + fn make_persistent_reply(text: &str) -> GatewayReply { + let mut reply = make_reply(None); + reply.reply_to.clear(); + reply.request_id = Some("request-persistent".into()); + reply.content.text = text.into(); + reply.persistent_conversation = Some(PersistentConversationTarget { + tenant_id: "tenant-1".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation-1".into(), + }); + reply + } + + fn install_persistent_test_route( + adapter: &TeamsAdapter, + service_url: &str, + ) -> TeamsConversationKey { + *adapter + .persistent_service_url_override + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + Some(reqwest::Url::parse(service_url).unwrap()); + let route = TeamsIngressRoute { + key: TeamsRouteKey::new("test-app", "tenant-1", "conversation-1", String::new()), + event_id: String::new(), + tenant_id: "tenant-1".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation-1".into(), + conversation_type: "personal".into(), + inbound_activity_id: String::new(), + reply_chain_root_id: None, + service_url: reqwest::Url::parse("https://smba.trafficmanager.net/teams").unwrap(), + team_id: None, + channel_id: None, + attachment_sources: HashMap::new(), + attachment_materialized_bytes: 0, + created_at: Instant::now(), + }; + adapter + .conversation_registry + .as_ref() + .expect("persistent test registry") + .lock() + .unwrap() + .insert_route_unchecked_for_test(&route, chrono::Utc::now()); + key_from_parts("test-app", "tenant-1", "msteams", "conversation-1").unwrap() + } + + fn persistent_entry( + adapter: &TeamsAdapter, + key: &TeamsConversationKey, + ) -> TeamsConversationEntry { + adapter + .conversation_registry + .as_ref() + .expect("persistent test registry") + .lock() + .unwrap() + .entry_for_test(key) + .expect("persistent test entry") + } + + #[test] + fn unsafe_registry_path_disables_only_the_persistent_capability() { + let mut config = make_config(vec![]); + config.conversation_registry_path = Some("../unsafe-registry.json".into()); + let adapter = TeamsAdapter::new_for_test(config); + assert!(!adapter.conversation_registry_available()); + assert!(!adapter.reactions_enabled()); + } + + #[tokio::test] + async fn trusted_registration_is_scoped_persisted_and_restart_safe() -> anyhow::Result<()> { + let (directory, path) = registry_test_path("roundtrip"); + let mut config = make_config(vec![]); + config.conversation_registry_path = Some(path.to_string_lossy().into_owned()); + let adapter = TeamsAdapter::new_for_test(config.clone()); + assert!(adapter.conversation_registry_available()); + adapter + .accept_route_for_test( + "https://smba.trafficmanager.net/teams", + "evt-1", + "tenant-1", + "conversation-1", + "inbound-secret", + None, + ) + .await?; + + assert!(matches!( + handle_reply(&make_reply(Some("register_conversation")), &adapter).await, + WriteOutcome::Delivered { message_id: None } + )); + assert_eq!( + adapter.conversation_registry_counts(), + Some(RegistryCounts { + active: 1, + disabled: 0, + revoked: 0, + }) + ); + assert!(matches!( + handle_reply(&make_reply(Some("register_conversation")), &adapter).await, + WriteOutcome::Delivered { message_id: None } + )); + + let mut cross_conversation = make_reply(Some("register_conversation")); + cross_conversation.channel.id = "other-conversation".into(); + assert!(matches!( + handle_reply(&cross_conversation, &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "conversation_mismatch" + )); + let raw = std::fs::read_to_string(&path)?; + assert!(!raw.contains("evt-1")); + assert!(!raw.contains("inbound-secret")); + drop(adapter); + + let reopened = TeamsAdapter::new_for_test(config); + assert_eq!(reopened.conversation_registry_counts().unwrap().active, 1); + std::fs::remove_dir_all(directory)?; + Ok(()) + } + + #[test] + fn exact_forbidden_classifier_accepts_only_structured_codes() { + assert_eq!( + exact_forbidden_code(br#"{"error":{"code":"MessageWritesBlocked"}}"#), + Some("message_writes_blocked") + ); + assert_eq!( + exact_forbidden_code(br#"{"error":{"code":"BotNotInConversationRoster"}}"#), + Some("bot_not_in_conversation_roster") + ); + assert_eq!( + exact_forbidden_code( + br#"{"error":{"code":"OtherForbidden","message":"MessageWritesBlocked"}}"# + ), + None + ); + assert_eq!(exact_forbidden_code(b"MessageWritesBlocked"), None); + } + + #[tokio::test] + async fn persistent_send_is_exact_outcome_aware_and_reconciles_state() -> anyhow::Result<()> { + let connector = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&connector) + .await; + let connector_body = |text: &str| { + serde_json::json!({ + "type": "message", + "from": { "id": "test-app" }, + "text": text, + "textFormat": "markdown" + }) + }; + let _generic = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(connector_body("generic"))) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "error": { "code": "OtherForbidden", "message": "generic-body-secret" } + }))) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _unknown = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(connector_body("unknown"))) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "error": { "code": "Transient", "message": "server-body-secret" } + }))) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _blocked = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(connector_body("blocked"))) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "error": { "code": "MessageWritesBlocked", "message": "blocked-body-secret" } + }))) + .expect(3) + .mount_as_scoped(&connector) + .await; + let _delivered = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(connector_body("delivered"))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "persistent-activity-1"})), + ) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _edit_rate_limited = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/persistent-activity-1", + )) + .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0")) + .expect(1) + .mount_as_scoped(&connector) + .await; + + let (directory, path) = registry_test_path("persistent-send"); + let mut config = make_http_test_config(&connector); + config.allowed_tenants = vec!["tenant-1".into()]; + config.conversation_registry_path = Some(path.to_string_lossy().into_owned()); + let adapter = TeamsAdapter::new_for_test(config); + let key = install_persistent_test_route(&adapter, &connector.uri()); + + let mut mismatch = make_persistent_reply("never"); + mismatch.channel.id = "other-conversation".into(); + assert!(matches!( + handle_reply(&mismatch, &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "persistent_target_mismatch" + )); + let mut wrong_tenant = make_persistent_reply("never"); + wrong_tenant + .persistent_conversation + .as_mut() + .unwrap() + .tenant_id = "tenant-2".into(); + assert!(matches!( + handle_reply(&wrong_tenant, &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "persistent_target_rejected" + )); + let mut wrong_transport = make_persistent_reply("never"); + wrong_transport + .persistent_conversation + .as_mut() + .unwrap() + .bot_framework_channel_id = "other-channel".into(); + assert!(matches!( + handle_reply(&wrong_transport, &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "persistent_target_rejected" + )); + let mut missing = make_persistent_reply("never"); + missing.channel.id = "missing-conversation".into(); + missing + .persistent_conversation + .as_mut() + .unwrap() + .conversation_id = "missing-conversation".into(); + assert!(matches!( + handle_reply(&missing, &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "persistent_route_unavailable" + )); + + let generic = handle_reply(&make_persistent_reply("generic"), &adapter).await; + assert!(matches!( + &generic, + WriteOutcome::Rejected { code, message, .. } + if code == "authorization_rejected" + && !message.contains("generic-body-secret") + )); + assert_eq!( + persistent_entry(&adapter, &key).consecutive_forbidden_writes, + 0 + ); + + let unknown = handle_reply(&make_persistent_reply("unknown"), &adapter).await; + assert!(matches!( + &unknown, + WriteOutcome::Unknown { code, message } + if code == "connector_server_error" + && !message.contains("server-body-secret") + )); + assert_eq!( + persistent_entry(&adapter, &key).consecutive_forbidden_writes, + 0 + ); + + let first_blocked = handle_reply(&make_persistent_reply("blocked"), &adapter).await; + assert!(matches!( + &first_blocked, + WriteOutcome::Rejected { code, message, .. } + if code == "message_writes_blocked" + && !message.contains("blocked-body-secret") + )); + assert_eq!( + persistent_entry(&adapter, &key).consecutive_forbidden_writes, + 1 + ); + + assert_eq!( + handle_reply(&make_persistent_reply("delivered"), &adapter).await, + WriteOutcome::Delivered { + message_id: Some("persistent-activity-1".into()) + } + ); + assert_eq!( + persistent_entry(&adapter, &key).consecutive_forbidden_writes, + 0 + ); + + let mut edit = make_persistent_reply("edited"); + edit.command = Some("edit_message".into()); + edit.target_message_id = Some("persistent-activity-1".into()); + assert!(matches!( + handle_reply(&edit, &adapter).await, + WriteOutcome::Rejected { code, retry_after_ms, .. } + if code == "rate_limited" && retry_after_ms == Some(0) + )); + assert_eq!( + persistent_entry(&adapter, &key).consecutive_forbidden_writes, + 0 + ); + + assert!(matches!( + handle_reply(&make_persistent_reply("blocked"), &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "message_writes_blocked" + )); + assert!(matches!( + handle_reply(&make_persistent_reply("blocked"), &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "message_writes_blocked" + )); + assert_eq!(adapter.conversation_registry_counts().unwrap().disabled, 1); + assert!(matches!( + handle_reply(&make_persistent_reply("never"), &adapter).await, + WriteOutcome::Rejected { code, .. } if code == "persistent_route_unavailable" + )); + + drop(adapter); + std::fs::remove_dir_all(directory)?; + Ok(()) + } + + #[tokio::test] + async fn authenticated_installation_removal_revokes_without_creating() -> anyhow::Result<()> { + let (directory, path) = registry_test_path("revoke"); + let mut config = make_config(vec![]); + config.conversation_registry_path = Some(path.to_string_lossy().into_owned()); + let adapter = TeamsAdapter::new_for_test(config); + adapter + .accept_route_for_test( + "https://smba.trafficmanager.net/teams", + "evt-1", + "tenant-1", + "conversation-1", + "inbound-1", + None, + ) + .await?; + assert!(matches!( + handle_reply(&make_reply(Some("register_conversation")), &adapter).await, + WriteOutcome::Delivered { .. } + )); + + let removal = Activity { + activity_type: "installationUpdate".into(), + id: Some("installation-event".into()), + timestamp: None, + service_url: Some("https://smba.trafficmanager.net/teams".into()), + channel_id: Some("msteams".into()), + from: None, + recipient: None, + conversation: Some(ConversationAccount { + id: Some("conversation-1".into()), + conversation_type: Some("personal".into()), + is_group: Some(false), + tenant_id: None, + }), + text: None, + tenant: Some(TenantInfo { + id: Some("tenant-1".into()), + }), + channel_data: None, + reply_to_id: None, + action: Some("remove".into()), + entities: vec![], + attachments: vec![], + }; + assert_eq!( + handle_installation_update(&adapter, &removal).await, + StatusCode::OK + ); + assert_eq!(adapter.conversation_registry_counts().unwrap().revoked, 1); + + let mut add = removal.clone(); + add.action = Some("add".into()); + assert_eq!( + handle_installation_update(&adapter, &add).await, + StatusCode::OK + ); + assert_eq!(adapter.conversation_registry_counts().unwrap().revoked, 1); + + let mut unknown = removal; + unknown.conversation.as_mut().unwrap().id = Some("unknown".into()); + assert_eq!( + handle_installation_update(&adapter, &unknown).await, + StatusCode::OK + ); + assert_eq!(adapter.conversation_registry_counts().unwrap().revoked, 1); + std::fs::remove_dir_all(directory)?; + Ok(()) + } + + async fn accept_test_route( + adapter: &TeamsAdapter, + service_url: &str, + event_id: &str, + activity_id: &str, + reply_chain_root_id: Option<&str>, + ) -> anyhow::Result<()> { + adapter + .accept_route_for_test( + service_url, + event_id, + "tenant-1", + "conversation-1", + activity_id, + reply_chain_root_id, + ) + .await + } + + fn make_activity_with_tenant(tenant_id: Option<&str>) -> Activity { + Activity { + activity_type: "message".into(), + id: Some("act1".into()), + timestamp: None, + service_url: Some("https://smba.trafficmanager.net/".into()), + channel_id: Some("msteams".into()), + from: None, + recipient: None, + conversation: None, + text: Some("hello".into()), + tenant: tenant_id.map(|id| TenantInfo { + id: Some(id.into()), + }), + channel_data: None, + reply_to_id: None, + action: None, + entities: vec![], + attachments: vec![], + } + } + + fn make_attachment_state( + config: TeamsConfig, + ) -> ( + Arc, + tokio::sync::broadcast::Receiver, + ) { + let (event_tx, event_rx) = tokio::sync::broadcast::channel(16); + let state = Arc::new(crate::AppState { + teams: Some(TeamsAdapter::new_for_test(config)), + ..crate::AppState::test_default(event_tx) + }); + (state, event_rx) + } + + fn make_routable_activity(activity_id: &str) -> Activity { + Activity { + activity_type: "message".into(), + id: Some(activity_id.into()), + timestamp: None, + service_url: Some("https://smba.trafficmanager.net/emea/".into()), + channel_id: Some("msteams".into()), + from: Some(ChannelAccount { + id: Some("29:user".into()), + name: Some("Alice".into()), + aad_object_id: None, + }), + recipient: Some(ChannelAccount { + id: Some("28:bot".into()), + name: Some("OpenAB".into()), + aad_object_id: None, + }), + conversation: Some(ConversationAccount { + id: Some("conversation-1".into()), + conversation_type: Some("channel".into()), + is_group: Some(true), + tenant_id: None, + }), + text: Some("hello".into()), + tenant: Some(TenantInfo { + id: Some("tenant-1".into()), + }), + channel_data: Some(ChannelData { + tenant: None, + team: Some(ChannelDataEntity { + id: Some("team-1".into()), + }), + channel: Some(ChannelDataEntity { + id: Some("channel-1".into()), + }), + }), + reply_to_id: Some("root-activity".into()), + action: None, + entities: vec![], + attachments: vec![], + } + } - let body = serde_json::json!({ - "type": "message", - "from": { "id": &self.config.app_id }, - "text": text, + fn make_personal_attachment_activity( + activity_id: &str, + service_url: &str, + attachment: ActivityAttachment, + ) -> Activity { + let mut activity = make_routable_activity(activity_id); + activity.service_url = Some(service_url.into()); + activity.text = None; + activity.conversation = Some(ConversationAccount { + id: Some("conversation-1".into()), + conversation_type: Some("personal".into()), + is_group: Some(false), + tenant_id: None, }); + activity.channel_data = Some(ChannelData { + tenant: None, + team: None, + channel: None, + }); + activity.attachments = vec![attachment]; + activity + } - let resp = self - .client - .put(&url) - .bearer_auth(&token) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("Bot Framework update error {status}: {body}"); + fn inline_image_attachment(url: &str) -> ActivityAttachment { + ActivityAttachment { + content_type: "image/png".into(), + content_url: Some(url.into()), + name: Some("image.png".into()), + content: None, } - Ok(()) } -} -fn ensure_trailing_slash(url: &str) -> String { - if url.ends_with('/') { - url.to_string() - } else { - format!("{url}/") + fn personal_file_attachment( + url: &str, + filename: &str, + file_size: Option, + ) -> ActivityAttachment { + ActivityAttachment { + content_type: TEAMS_FILE_DOWNLOAD_INFO_TYPE.into(), + content_url: None, + name: Some(filename.into()), + content: Some(serde_json::json!({ + "downloadUrl": url, + "fileSize": file_size, + })), + } } -} -// --- Webhook handler --- + fn tiny_png() -> Vec { + let image = image::DynamicImage::new_rgb8(2, 2); + let mut output = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut output, image::ImageFormat::Png) + .expect("test PNG encoding"); + output.into_inner() + } -/// Max webhook body size: 256 KB. Real Teams activities are a few KB; the -/// activity is parsed *before* JWT auth (Bot Framework requires serviceUrl / -/// channelId from the body to validate the token), so this caps the -/// unauthenticated parse attack surface. Mirrors the feishu adapter's limit. -const WEBHOOK_BODY_LIMIT: usize = 256 * 1024; + #[tokio::test] + async fn attachment_only_is_ignored_when_disabled_and_publishes_opaque_metadata_when_enabled( + ) -> anyhow::Result<()> { + let content_url = "https://smba.trafficmanager.net/emea/v3/attachments/private/views/original?opaque=secret"; + let activity = make_personal_attachment_activity( + "attachment-disabled", + "https://smba.trafficmanager.net/emea/", + inline_image_attachment(content_url), + ); + let (disabled_state, mut disabled_rx) = make_routable_state(); + assert_eq!( + accept_message_activity(disabled_state.clone(), activity.clone()).await, + StatusCode::OK + ); + assert!(matches!( + disabled_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); -pub async fn webhook( - State(state): State>, - headers: HeaderMap, - body: String, -) -> StatusCode { - let teams = match &state.teams { - Some(t) => t, - None => return StatusCode::NOT_FOUND, - }; + let mut config = make_config(vec![]); + config.inbound_attachments = true; + let (enabled_state, mut enabled_rx) = make_attachment_state(config); + assert_eq!( + accept_message_activity(enabled_state.clone(), activity).await, + StatusCode::OK + ); + let event_json = enabled_rx.recv().await?; + assert!(!event_json.contains("opaque=secret")); + assert!(!event_json.contains("/attachments/private/")); + let event: GatewayEvent = serde_json::from_str(&event_json)?; + assert!(event.content.text.is_empty()); + assert_eq!(event.content.attachments.len(), 1); + let reference = event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("opaque reference missing"))?; + assert!(reference.starts_with("att_")); + assert!(event.content.attachments[0].data.is_empty()); + assert!(event.content.attachments[0].path.is_none()); - // Defense-in-depth: bound the pre-auth body size (axum's default limit is 2 MB). - if body.len() > WEBHOOK_BODY_LIMIT { - warn!(size = body.len(), "teams webhook body too large"); - return StatusCode::PAYLOAD_TOO_LARGE; + let route = enabled_state + .teams + .as_ref() + .expect("Teams adapter") + .ingress + .lock() + .await + .route_for_event(&event.event_id, Instant::now()) + .ok_or_else(|| anyhow::anyhow!("attachment route missing"))?; + assert_eq!(route.attachment_sources.len(), 1); + assert!(route.attachment_sources.contains_key(reference)); + Ok(()) } - // Extract auth header early (before parsing activity) - let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { - Some(h) => h.to_string(), - None => { - warn!("teams webhook: missing authorization header"); - return StatusCode::UNAUTHORIZED; - } - }; - - // Parse activity first (needed for JWT serviceUrl + endorsements validation). - // - // SECURITY NOTE (OX untrusted-deserialization finding — false positive): - // `Activity` is a strict, derive-only DTO (String / Option<_> / nested - // structs) with no custom Deserialize, no side-effectful Drop, and no enum - // variant dispatch. serde_json's data model cannot instantiate arbitrary - // types (unlike bincode/serde_yaml/rmp-serde), so object-injection / RCE - // does not apply. The recommended "strict DTO + validate after" pattern is - // already in place: JWT, activity-type, and tenant-allowlist checks below. - // DoS is bounded by serde_json's recursion limit (128) and the body cap above. - let activity: Activity = match serde_json::from_str(&body) { - Ok(a) => a, - Err(e) => { - warn!(error = %e, "teams: invalid activity JSON"); - return StatusCode::BAD_REQUEST; - } - }; + #[tokio::test] + async fn inline_image_materializes_once_with_bot_auth_after_route_acceptance( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "attachment-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let png = tiny_png(); + let _image = Mock::given(method("GET")) + .and(path("/inline")) + .and(header("authorization", "Bearer attachment-token")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(png)) + .expect(1) + .mount_as_scoped(&server) + .await; - // JWT validation (with activity context for serviceUrl + channelId checks) - if let Err(e) = teams.validate_jwt(&auth_header, &activity).await { - warn!(error = %e, "teams JWT validation failed"); - return StatusCode::UNAUTHORIZED; - } + let mut config = make_http_test_config(&server); + config.inbound_attachments = true; + let (state, mut event_rx) = make_attachment_state(config); + let activity = make_personal_attachment_activity( + "inline-materialize", + &server.uri(), + inline_image_attachment(&format!("{}/inline?sig=private", server.uri())), + ); + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::OK + ); + let event_json = event_rx.recv().await?; + assert!(!event_json.contains("sig=private")); + let event: GatewayEvent = serde_json::from_str(&event_json)?; + let reference = event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("opaque reference missing"))?; + let teams = state.teams.as_ref().expect("Teams adapter"); + let attachment = teams + .materialize_attachment(&event.event_id, &event.channel.id, reference) + .await?; + assert!(attachment.status.is_none()); + assert_eq!(attachment.mime_type, "image/jpeg"); + assert!(attachment.reference.is_none()); + assert!(attachment.path.is_none()); + let decoded = attachment.decoded_data()?; + assert!(!decoded.is_empty()); + assert_eq!(attachment.size, decoded.len() as u64); - // Only handle message activities - if activity.activity_type != "message" { - debug!(activity_type = %activity.activity_type, "teams: ignoring non-message activity"); - return StatusCode::OK; + let second = teams + .materialize_attachment(&event.event_id, &event.channel.id, reference) + .await + .expect_err("an opaque reference must be single-use"); + assert_eq!(second.code(), "attachment_reference_not_found"); + Ok(()) } - // Tenant check - if !teams.check_tenant(&activity) { - let tid = activity.resolved_tenant_id().unwrap_or("unknown"); - warn!(tenant = tid, "teams: tenant not in allowlist"); - return StatusCode::FORBIDDEN; - } + #[tokio::test] + async fn personal_text_materialization_never_sends_bot_auth_and_rejects_non_utf8( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let _text = Mock::given(method("GET")) + .and(path("/notes")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello teams")) + .expect(1) + .mount_as_scoped(&server) + .await; + let _binary = Mock::given(method("GET")) + .and(path("/invalid")) + .respond_with(ResponseTemplate::new(200).set_body_bytes([0xff, 0xfe])) + .expect(1) + .mount_as_scoped(&server) + .await; - let text = match activity.text.as_deref() { - Some(t) if !t.trim().is_empty() => t.trim(), - _ => return StatusCode::OK, - }; + let mut config = make_http_test_config(&server); + config.inbound_attachments = true; + let (state, mut event_rx) = make_attachment_state(config); + let text_activity = make_personal_attachment_activity( + "text-materialize", + &server.uri(), + personal_file_attachment( + &format!("{}/notes?sig=private", server.uri()), + "notes.md", + Some(11), + ), + ); + assert_eq!( + accept_message_activity(state.clone(), text_activity).await, + StatusCode::OK + ); + let event: GatewayEvent = serde_json::from_str(&event_rx.recv().await?)?; + let reference = event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("text reference missing"))?; + let teams = state.teams.as_ref().expect("Teams adapter"); + let attachment = teams + .materialize_attachment(&event.event_id, &event.channel.id, reference) + .await?; + assert_eq!(attachment.decoded_data()?, b"hello teams"); + assert_eq!(attachment.mime_type, "text/plain; charset=utf-8"); - let conversation_id = activity - .conversation - .as_ref() - .and_then(|c| c.id.as_deref()) - .unwrap_or(""); - let conversation_type = activity - .conversation - .as_ref() - .and_then(|c| c.conversation_type.as_deref()) - .unwrap_or("personal"); - let service_url = activity.service_url.as_deref().unwrap_or(""); - let sender_id = activity - .from - .as_ref() - .and_then(|f| f.id.as_deref()) - .unwrap_or(""); - let sender_name = activity - .from - .as_ref() - .and_then(|f| f.name.as_deref()) - .unwrap_or("Unknown"); - let activity_id = activity.id.as_deref().unwrap_or(""); + let invalid_activity = make_personal_attachment_activity( + "invalid-text", + &server.uri(), + personal_file_attachment( + &format!("{}/invalid?sig=private", server.uri()), + "invalid.txt", + Some(2), + ), + ); + assert_eq!( + accept_message_activity(state.clone(), invalid_activity).await, + StatusCode::OK + ); + let invalid_event: GatewayEvent = serde_json::from_str(&event_rx.recv().await?)?; + let invalid_reference = invalid_event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("invalid text reference missing"))?; + let rejected = teams + .materialize_attachment( + &invalid_event.event_id, + &invalid_event.channel.id, + invalid_reference, + ) + .await?; + assert!(rejected.data.is_empty()); + assert!(rejected + .status + .as_deref() + .is_some_and(|status| status.starts_with("invalid content:"))); - // B3: Guard against empty service_url — replies will fail without it - if service_url.is_empty() { - warn!("teams: activity missing service_url, cannot route replies"); - return StatusCode::OK; + let requests = server + .received_requests() + .await + .ok_or_else(|| anyhow::anyhow!("request recording is disabled"))?; + for request in requests + .iter() + .filter(|request| matches!(request.url.path(), "/notes" | "/invalid")) + { + assert!(!request.headers.contains_key("authorization")); + } + assert!(!requests + .iter() + .any(|request| request.url.path() == "/token")); + Ok(()) } - let event = GatewayEvent::new( - "teams", - ChannelInfo { - id: conversation_id.to_string(), - channel_type: conversation_type.to_string(), - thread_id: None, // Teams conversations don't have sub-threads in the same way - }, - SenderInfo { - id: sender_id.to_string(), - name: sender_name.to_string(), - display_name: sender_name.to_string(), - is_bot: false, - }, - text, - activity_id, - vec![], // Teams @mentions parsing deferred to future PR - ); - - // Store service_url for reply routing - state.teams_service_urls.lock().await.insert( - conversation_id.to_string(), - (service_url.to_string(), std::time::Instant::now()), - ); - - let json = serde_json::to_string(&event).unwrap(); - let tenant_id = activity.resolved_tenant_id().unwrap_or(""); - info!( - conversation = conversation_id, - sender = sender_name, - tenant = tenant_id, - service_url = service_url, - "teams → gateway" - ); - let _ = state.event_tx.send(json); + #[tokio::test] + async fn inline_redirect_cannot_forward_bot_auth_to_another_origin() -> anyhow::Result<()> { + let source = MockServer::start().await; + let target = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "attachment-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&source) + .await; + let _redirect = Mock::given(method("GET")) + .and(path("/inline")) + .and(header("authorization", "Bearer attachment-token")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/target", target.uri())), + ) + .expect(1) + .mount_as_scoped(&source) + .await; + let _target = Mock::given(method("GET")) + .and(path("/target")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(tiny_png())) + .expect(0) + .mount_as_scoped(&target) + .await; - StatusCode::OK -} + let mut config = make_http_test_config(&source); + config.inbound_attachments = true; + let (state, mut event_rx) = make_attachment_state(config); + let activity = make_personal_attachment_activity( + "redirect-image", + &source.uri(), + inline_image_attachment(&format!("{}/inline", source.uri())), + ); + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::OK + ); + let event: GatewayEvent = serde_json::from_str(&event_rx.recv().await?)?; + let reference = event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("redirect reference missing"))?; + let rejected = state + .teams + .as_ref() + .expect("Teams adapter") + .materialize_attachment(&event.event_id, &event.channel.id, reference) + .await?; + assert!(rejected + .status + .as_deref() + .is_some_and(|status| status.starts_with("security rejected:"))); + Ok(()) + } -// --- Reply handler --- + #[tokio::test] + async fn attachment_metadata_and_download_limits_are_enforced() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _oversized = Mock::given(method("GET")) + .and(path("/oversized")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![ + 0; + TEAMS_TEXT_DOWNLOAD_LIMIT + as usize + + 1 + ])) + .expect(1) + .mount_as_scoped(&server) + .await; + let mut config = make_http_test_config(&server); + config.inbound_attachments = true; + let (state, mut event_rx) = make_attachment_state(config); + let activity = make_personal_attachment_activity( + "oversized-text", + &server.uri(), + personal_file_attachment( + &format!("{}/oversized?sig=private", server.uri()), + "notes.txt", + None, + ), + ); + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::OK + ); + let event: GatewayEvent = serde_json::from_str(&event_rx.recv().await?)?; + let reference = event.content.attachments[0] + .reference + .as_deref() + .ok_or_else(|| anyhow::anyhow!("oversized reference missing"))?; + let rejected = state + .teams + .as_ref() + .expect("Teams adapter") + .materialize_attachment(&event.event_id, &event.channel.id, reference) + .await?; + let rejection = rejected + .status + .as_deref() + .ok_or_else(|| anyhow::anyhow!("oversized attachment was not rejected"))?; + assert!(rejection.starts_with("size exceeded:"), "{rejection}"); -pub async fn handle_reply( - reply: &GatewayReply, - teams: &TeamsAdapter, - service_urls: &tokio::sync::Mutex< - std::collections::HashMap, - >, -) { - // Reactions are not supported on Teams — silently ignore - if reply.command.as_deref() == Some("add_reaction") - || reply.command.as_deref() == Some("remove_reaction") - { - return; + let teams = state.teams.as_ref().expect("Teams adapter"); + let service = reqwest::Url::parse(&server.uri())?; + let mut many = make_personal_attachment_activity( + "many-attachments", + &server.uri(), + inline_image_attachment(&format!("{}/image-0", server.uri())), + ); + many.attachments = (0..12) + .map(|index| ActivityAttachment { + content_type: "image/png".into(), + content_url: Some(format!("{}/image-{index}", server.uri())), + name: Some(format!("{}-{index}.png", "a".repeat(240))), + content: None, + }) + .collect(); + let prepared = prepare_attachment_metadata(teams, &many, &service, "personal"); + assert_eq!(prepared.metadata.len(), TEAMS_ATTACHMENT_METADATA_LIMIT); + assert_eq!(prepared.sources.len(), TEAMS_ATTACHMENT_METADATA_LIMIT); + assert!(prepared + .metadata + .iter() + .all(|attachment| attachment.filename.chars().count() <= TEAMS_FILENAME_LIMIT)); + Ok(()) } - let service_url = { - let mut urls = service_urls.lock().await; - match urls.get_mut(&reply.channel.id) { - Some((url, ts)) => { - // Refresh timestamp on reply to prevent TTL expiry during active conversations - *ts = std::time::Instant::now(); - url.clone() - } - None => { - error!(conversation = %reply.channel.id, "teams: no service_url for conversation"); - return; - } + #[test] + fn attachment_url_and_scope_policy_is_fail_closed() -> anyhow::Result<()> { + let service = reqwest::Url::parse("https://smba.trafficmanager.net/emea/")?; + assert!(validate_inline_attachment_url( + "https://smba.trafficmanager.net/emea/attachment?sig=opaque", + &service, + false, + ) + .is_ok()); + assert!( + validate_inline_attachment_url("https://evil.example/attachment", &service, false,) + .is_err() + ); + assert!(validate_file_attachment_url( + "https://tenant.sharepoint.com/file?sig=opaque", + false, + ) + .is_ok()); + for unsafe_url in [ + "http://tenant.sharepoint.com/file", + "https://127.0.0.1/file", + "https://evilsharepoint.com/file", + "https://tenant.sharepoint.com:444/file", + "https://user@tenant.sharepoint.com/file", + "https://tenant.sharepoint.com/file#fragment", + ] { + assert!(validate_file_attachment_url(unsafe_url, false).is_err()); } - }; - let reply_to_id = if reply.reply_to.is_empty() { - None - } else { - Some(reply.reply_to.as_str()) - }; + let mut config = make_config(vec![]); + config.inbound_attachments = true; + let adapter = TeamsAdapter::new(config); + let group_attachment = personal_file_attachment( + "https://tenant.sharepoint.com/file?sig=opaque", + "notes.txt", + Some(5), + ); + let activity = + make_personal_attachment_activity("group-file", service.as_str(), group_attachment); + let prepared = prepare_attachment_metadata(&adapter, &activity, &service, "groupChat"); + assert!(prepared.sources.is_empty()); + assert_eq!(prepared.metadata.len(), 1); + assert!(prepared.metadata[0] + .status + .as_deref() + .is_some_and(|status| status.starts_with("unsupported format:"))); - info!(conversation = %reply.channel.id, "gateway → teams"); - match teams - .send_activity( - &service_url, - &reply.channel.id, - &reply.content.text, - reply_to_id, - ) - .await - { - Ok(id) => debug!(activity_id = %id, "teams activity sent"), - Err(e) => error!(error = %e, "teams send error"), + let mut missing_scope = activity; + missing_scope + .conversation + .as_mut() + .ok_or_else(|| anyhow::anyhow!("test activity is missing its conversation"))? + .conversation_type = None; + let prepared = + prepare_attachment_metadata(&adapter, &missing_scope, &service, "personal"); + assert!(prepared.sources.is_empty()); + assert!(prepared.metadata[0] + .status + .as_deref() + .is_some_and(|status| status.starts_with("unsupported format:"))); + Ok(()) } -} -#[cfg(test)] -mod tests { - use super::*; + // --- webhook body limit --- - // --- ensure_trailing_slash --- + #[tokio::test] + async fn webhook_rejects_oversized_body_before_auth() { + let status = webhook( + State(make_test_state()), + HeaderMap::new(), + "x".repeat(WEBHOOK_BODY_LIMIT + 1), + ) + .await; - #[test] - fn trailing_slash_adds_when_missing() { - assert_eq!( - ensure_trailing_slash("https://example.com"), - "https://example.com/" - ); + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); } - #[test] - fn trailing_slash_keeps_when_present() { - assert_eq!( - ensure_trailing_slash("https://example.com/"), - "https://example.com/" - ); + #[tokio::test] + async fn webhook_allows_body_at_limit_to_reach_auth() { + let status = webhook( + State(make_test_state()), + HeaderMap::new(), + "x".repeat(WEBHOOK_BODY_LIMIT), + ) + .await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); } - #[test] - fn trailing_slash_empty_string() { - assert_eq!(ensure_trailing_slash(""), "/"); + #[tokio::test] + async fn webhook_rejects_missing_route_fields_before_jwt_fetch() -> anyhow::Result<()> { + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer invalid".parse()?); + let status = webhook( + State(make_test_state()), + headers, + r#"{"type":"message","text":"hello"}"#.into(), + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + Ok(()) } - // --- check_tenant --- + #[tokio::test] + async fn post_auth_requires_all_route_and_identity_fields() -> anyhow::Result<()> { + let (state, _event_rx) = make_routable_state(); - fn make_config(tenants: Vec<&str>) -> TeamsConfig { - TeamsConfig { - app_id: "test-app".into(), - app_secret: "test-secret".into(), - oauth_endpoint: "https://example.com/token".into(), - openid_metadata: "https://example.com/openid".into(), - allowed_tenants: tenants.into_iter().map(|s| s.to_string()).collect(), + let mut cases = Vec::new(); + let mut missing_channel_id = make_routable_activity("missing-channel-id"); + missing_channel_id.channel_id = None; + cases.push(missing_channel_id); + let mut missing_tenant = make_routable_activity("missing-tenant"); + missing_tenant.tenant = None; + cases.push(missing_tenant); + let mut missing_conversation = make_routable_activity("missing-conversation"); + let Some(conversation) = missing_conversation.conversation.as_mut() else { + anyhow::bail!("test activity must include a conversation") + }; + conversation.id = None; + cases.push(missing_conversation); + let mut missing_activity = make_routable_activity("missing-activity"); + missing_activity.id = None; + cases.push(missing_activity); + let mut missing_sender = make_routable_activity("missing-sender"); + let Some(sender) = missing_sender.from.as_mut() else { + anyhow::bail!("test activity must include a sender") + }; + sender.id = None; + cases.push(missing_sender); + let mut missing_service_url = make_routable_activity("missing-service-url"); + missing_service_url.service_url = None; + cases.push(missing_service_url); + + for activity in cases { + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::BAD_REQUEST + ); } + Ok(()) } - fn make_test_state() -> Arc { - let (event_tx, _rx) = tokio::sync::broadcast::channel(16); - - Arc::new(crate::AppState { + #[tokio::test] + async fn no_consumer_returns_503_without_leaving_a_dedupe_tombstone() -> anyhow::Result<()> { + let (event_tx, event_rx) = tokio::sync::broadcast::channel(16); + drop(event_rx); + let state = Arc::new(crate::AppState { teams: Some(TeamsAdapter::new(make_config(vec![]))), ..crate::AppState::test_default(event_tx) - }) - } + }); + let activity = make_routable_activity("retryable-activity"); - fn make_activity_with_tenant(tenant_id: Option<&str>) -> Activity { - Activity { - activity_type: "message".into(), - id: Some("act1".into()), - timestamp: None, - service_url: Some("https://smba.trafficmanager.net/".into()), - channel_id: Some("msteams".into()), - from: None, - conversation: None, - text: Some("hello".into()), - tenant: tenant_id.map(|id| TenantInfo { - id: Some(id.into()), - }), - channel_data: None, - } + assert_eq!( + accept_message_activity(state.clone(), activity.clone()).await, + StatusCode::SERVICE_UNAVAILABLE + ); + let mut event_rx = state.event_tx.subscribe(); + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::OK + ); + let event_json = event_rx.recv().await?; + let event: GatewayEvent = serde_json::from_str(&event_json)?; + let teams = state + .teams + .as_ref() + .ok_or_else(|| anyhow::anyhow!("test state must include Teams"))?; + let route = teams + .ingress + .lock() + .await + .route_for_event(&event.event_id, Instant::now()) + .ok_or_else(|| anyhow::anyhow!("successful retry should commit an ingress route"))?; + assert_eq!(route.tenant_id, "tenant-1"); + assert_eq!(route.bot_framework_channel_id, "msteams"); + assert_eq!(route.conversation_id, "conversation-1"); + assert_eq!(route.inbound_activity_id, "retryable-activity"); + assert_eq!(route.reply_chain_root_id.as_deref(), Some("root-activity")); + assert_eq!(route.team_id.as_deref(), Some("team-1")); + assert_eq!(route.channel_id.as_deref(), Some("channel-1")); + Ok(()) } - // --- webhook body limit --- - #[tokio::test] - async fn webhook_rejects_oversized_body_before_auth() { - let status = webhook( - State(make_test_state()), - HeaderMap::new(), - "x".repeat(WEBHOOK_BODY_LIMIT + 1), - ) - .await; + async fn accepted_duplicate_publishes_exactly_one_gateway_event() -> anyhow::Result<()> { + let (state, mut event_rx) = make_routable_state(); + let activity = make_routable_activity("duplicate-activity"); - assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + accept_message_activity(state.clone(), activity.clone()).await, + StatusCode::OK + ); + assert_eq!( + accept_message_activity(state.clone(), activity).await, + StatusCode::OK + ); + event_rx.recv().await?; + assert!(matches!( + event_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + Ok(()) } #[tokio::test] - async fn webhook_allows_body_at_limit_to_reach_auth() { - let status = webhook( - State(make_test_state()), - HeaderMap::new(), - "x".repeat(WEBHOOK_BODY_LIMIT), - ) - .await; + async fn concurrent_duplicate_waiters_share_one_publish_result() -> anyhow::Result<()> { + let (state, mut event_rx) = make_routable_state(); + let activity = make_routable_activity("concurrent-activity"); + let mut tasks = Vec::new(); + for _ in 0..16 { + tasks.push(tokio::spawn(accept_message_activity( + state.clone(), + activity.clone(), + ))); + } + for task in tasks { + assert_eq!(task.await?, StatusCode::OK); + } - assert_eq!(status, StatusCode::UNAUTHORIZED); + event_rx.recv().await?; + assert!(matches!( + event_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + Ok(()) } #[test] @@ -745,42 +5164,46 @@ mod tests { // --- resolved_tenant_id --- #[test] - fn resolved_tenant_falls_back_to_channel_data() { + fn resolved_tenant_falls_back_to_channel_data() -> anyhow::Result<()> { // Teams personal/channel webhooks put tenant in channelData, not top-level let json = r#"{ "type": "message", "channelData": {"tenant": {"id": "from-channel-data"}} }"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.resolved_tenant_id(), Some("from-channel-data")); + Ok(()) } #[test] - fn resolved_tenant_prefers_top_level_over_channel_data() { + fn resolved_tenant_prefers_top_level_over_channel_data() -> anyhow::Result<()> { let json = r#"{ "type": "message", "tenant": {"id": "top-level"}, "channelData": {"tenant": {"id": "from-channel-data"}} }"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.resolved_tenant_id(), Some("top-level")); + Ok(()) } #[test] - fn resolved_tenant_falls_back_to_conversation_tenant_id() { + fn resolved_tenant_falls_back_to_conversation_tenant_id() -> anyhow::Result<()> { let json = r#"{ "type": "message", "conversation": {"id": "c1", "tenantId": "from-conversation"} }"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.resolved_tenant_id(), Some("from-conversation")); + Ok(()) } #[test] - fn resolved_tenant_returns_none_when_absent() { + fn resolved_tenant_returns_none_when_absent() -> anyhow::Result<()> { let json = r#"{"type": "message"}"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.resolved_tenant_id(), None); + Ok(()) } // --- validate_jwt error paths --- @@ -806,51 +5229,237 @@ mod tests { async fn jwt_rejects_garbage_token() { let adapter = TeamsAdapter::new(make_config(vec![])); let activity = make_activity_with_tenant(Some("t1")); - let result = adapter.validate_jwt("Bearer not.a.valid.jwt", &activity).await; + let result = adapter + .validate_jwt("Bearer not.a.valid.jwt", &activity) + .await; assert!(result.is_err()); } // --- Activity deserialization --- #[test] - fn deserialize_minimal_activity() { + fn deserialize_minimal_activity() -> anyhow::Result<()> { let json = r#"{"type": "message"}"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.activity_type, "message"); assert!(activity.text.is_none()); assert!(activity.from.is_none()); + Ok(()) } #[test] - fn deserialize_full_activity() { + fn deserialize_full_activity() -> anyhow::Result<()> { let json = r#"{ "type": "message", "id": "act123", "serviceUrl": "https://smba.trafficmanager.net/", "channelId": "msteams", "from": {"id": "user1", "name": "Alice", "aadObjectId": "aad-123"}, + "recipient": {"id": "bot1", "name": "OpenAB"}, "conversation": {"id": "conv1", "conversationType": "personal", "isGroup": false}, "text": "hello bot", - "tenant": {"id": "tenant-abc"} + "tenant": {"id": "tenant-abc"}, + "replyToId": "root-activity", + "channelData": { + "team": {"id": "team-abc"}, + "channel": {"id": "channel-abc"} + }, + "entities": [ + {"type": "mention", "mentioned": {"id": "bot1", "name": "OpenAB"}, "text": "OpenAB"}, + {"type": "mention", "mentioned": {"id": "user2", "name": "Bob"}, "text": "Bob"}, + {"type": "clientInfo"} + ] }"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.activity_type, "message"); assert_eq!(activity.text.as_deref(), Some("hello bot")); assert_eq!( - activity.from.as_ref().unwrap().name.as_deref(), + activity + .from + .as_ref() + .and_then(|sender| sender.name.as_deref()), Some("Alice") ); assert_eq!( - activity.tenant.as_ref().unwrap().id.as_deref(), + activity + .tenant + .as_ref() + .and_then(|tenant| tenant.id.as_deref()), Some("tenant-abc") ); + assert_eq!(activity.reply_to_id.as_deref(), Some("root-activity")); + assert_eq!( + activity + .recipient + .as_ref() + .and_then(|recipient| recipient.id.as_deref()), + Some("bot1") + ); + let channel_data = activity + .channel_data + .as_ref() + .ok_or_else(|| anyhow::anyhow!("channelData should deserialize"))?; + assert_eq!( + channel_data + .team + .as_ref() + .and_then(|team| team.id.as_deref()), + Some("team-abc") + ); + assert_eq!( + channel_data + .channel + .as_ref() + .and_then(|channel| channel.id.as_deref()), + Some("channel-abc") + ); + let (mention_ids, mention_entities) = activity.mention_info(); + assert_eq!(mention_ids, vec!["bot1", "user2"]); + assert_eq!( + mention_entities, + vec![ + MentionInfo { + id: "bot1".into(), + text: "OpenAB".into(), + }, + MentionInfo { + id: "user2".into(), + text: "Bob".into(), + }, + ] + ); + Ok(()) + } + + #[tokio::test] + async fn accepted_event_carries_typed_scope_recipient_and_mentions() -> anyhow::Result<()> { + let (state, mut event_rx) = make_routable_state(); + let mut activity = make_routable_activity("typed-activity"); + activity.text = Some("OpenAB ask Bob".into()); + activity.entities = vec![ + ActivityEntity { + entity_type: "mention".into(), + mentioned: Some(ChannelAccount { + id: Some("28:bot".into()), + name: Some("OpenAB".into()), + aad_object_id: None, + }), + text: Some("OpenAB".into()), + }, + ActivityEntity { + entity_type: "mention".into(), + mentioned: Some(ChannelAccount { + id: Some("29:bob".into()), + name: Some("Bob".into()), + aad_object_id: None, + }), + text: Some("Bob".into()), + }, + ActivityEntity { + entity_type: "mention".into(), + mentioned: Some(ChannelAccount { + id: Some("28:bot".into()), + name: Some("OpenAB".into()), + aad_object_id: None, + }), + text: Some(String::new()), + }, + ActivityEntity { + entity_type: "clientInfo".into(), + mentioned: None, + text: None, + }, + ]; + + assert_eq!( + accept_message_activity(state, activity).await, + StatusCode::OK + ); + let event: GatewayEvent = serde_json::from_str(&event_rx.recv().await?)?; + assert_eq!(event.channel.id, "conversation-1"); + assert_eq!(event.channel.channel_type, "channel"); + assert_eq!(event.content.text, "OpenAB ask Bob"); + assert_eq!(event.mentions, vec!["28:bot", "29:bob"]); + assert_eq!( + event.recipient, + Some(RecipientInfo { + id: "28:bot".into(), + name: "OpenAB".into(), + }) + ); + assert_eq!( + event.scope, + Some(GatewayScope { + tenant_id: Some("tenant-1".into()), + team_id: Some("team-1".into()), + channel_id: Some("channel-1".into()), + conversation_type: "channel".into(), + trust_scope_id: "teams:tenant-1:team:team-1:channel:channel-1".into(), + is_dm: false, + }) + ); + assert_eq!( + event.mention_entities, + vec![ + MentionInfo { + id: "28:bot".into(), + text: "OpenAB".into(), + }, + MentionInfo { + id: "29:bob".into(), + text: "Bob".into(), + }, + MentionInfo { + id: "28:bot".into(), + text: String::new(), + }, + ] + ); + Ok(()) + } + + #[test] + fn scope_derivation_canonicalizes_known_conversation_types() -> anyhow::Result<()> { + let mut activity = make_routable_activity("scope-activity"); + let conversation = activity + .conversation + .as_mut() + .ok_or_else(|| anyhow::anyhow!("test activity must include conversation"))?; + + conversation.conversation_type = Some("GROUPCHAT".into()); + activity.channel_data = None; + let kind = canonical_conversation_type("GROUPCHAT"); + let group = activity.gateway_scope("tenant-1", "conversation-1", &kind); + assert_eq!(group.conversation_type, "groupChat"); + assert!(!group.is_dm); + assert_eq!( + group.trust_scope_id, + "teams:tenant-1:group-chat:conversation-1" + ); + + let kind = canonical_conversation_type("Personal"); + let personal = activity.gateway_scope("tenant-1", "conversation-1", &kind); + assert_eq!(personal.conversation_type, "personal"); + assert!(personal.is_dm); + assert_eq!( + personal.trust_scope_id, + "teams:tenant-1:personal:conversation-1" + ); + + let kind = canonical_conversation_type("meeting"); + let unknown = activity.gateway_scope("tenant-1", "conversation-1", &kind); + assert_eq!(unknown.conversation_type, "meeting"); + assert!(!unknown.is_dm); + assert!(unknown.trust_scope_id.contains(":unknown:meeting:")); + Ok(()) } #[test] - fn deserialize_non_message_activity() { + fn deserialize_non_message_activity() -> anyhow::Result<()> { let json = r#"{"type": "conversationUpdate"}"#; - let activity: Activity = serde_json::from_str(json).unwrap(); + let activity: Activity = serde_json::from_str(json)?; assert_eq!(activity.activity_type, "conversationUpdate"); + Ok(()) } #[test] @@ -859,8 +5468,1045 @@ mod tests { assert!(result.is_err()); } + // --- transport concurrency and HTTP policy --- + + #[tokio::test] + async fn concurrent_oauth_callers_share_one_refresh() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(20)) + .set_body_json(serde_json::json!({ + "access_token": "singleflight-token", + "expires_in": 3600 + })), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let (first, second) = tokio::join!(adapter.get_token(), adapter.get_token()); + assert_eq!(first?, "singleflight-token"); + assert_eq!(second?, "singleflight-token"); + Ok(()) + } + + #[tokio::test] + async fn oauth_error_redacts_configured_app_secret() { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with( + ResponseTemplate::new(401) + .set_body_string("rejected test-secret at https://sensitive.example/token"), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let error = adapter.get_token().await.unwrap_err().to_string(); + assert!(error.contains("401")); + assert!(!error.contains("test-secret")); + assert!(!error.contains("sensitive.example")); + } + + #[tokio::test] + async fn concurrent_jwks_callers_share_metadata_and_key_fetches() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _metadata = Mock::given(method("GET")) + .and(path("/openid")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jwks_uri": format!("{}/keys", server.uri()) + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _keys = Mock::given(method("GET")) + .and(path("/keys")) + .respond_with( + ResponseTemplate::new(200) + .set_delay(Duration::from_millis(20)) + .set_body_json(serde_json::json!({ + "keys": [{ + "kid": "key-1", + "n": "modulus", + "e": "AQAB", + "kty": "RSA", + "endorsements": ["msteams"] + }] + })), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let (first, second) = tokio::join!(adapter.get_jwks(), adapter.get_jwks()); + assert_eq!(first?.keys.len(), 1); + assert_eq!(second?.keys.len(), 1); + Ok(()) + } + + #[tokio::test] + async fn unsafe_service_url_is_rejected_before_oauth() { + let server = MockServer::start().await; + let _no_token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new(make_http_test_config(&server)); + + let error = adapter + .send_activity("http://127.0.0.1/", "conversation-1", "hello", None) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("HTTPS")); + } + + #[tokio::test] + async fn connector_success_without_activity_id_is_unknown() { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let outcome = adapter + .send_activity_outcome(&server.uri(), "conversation-1", "hello", None) + .await; + assert_eq!( + outcome, + WriteOutcome::Unknown { + code: "missing_activity_id".into(), + message: "Bot Framework send response missing activity id".into(), + } + ); + } + + #[tokio::test] + async fn connector_does_not_follow_cross_origin_redirects() { + let source = MockServer::start().await; + let target = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&source) + .await; + let _redirect = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with( + ResponseTemplate::new(307) + .insert_header("location", format!("{}/captured", target.uri())), + ) + .expect(1) + .mount_as_scoped(&source) + .await; + let _not_reached = Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount_as_scoped(&target) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&source)); + + let error = adapter + .send_activity(&source.uri(), "conversation-1", "hello", None) + .await + .unwrap_err(); + assert!(error.to_string().contains("307")); + } + + #[tokio::test] + async fn connector_error_body_is_bounded_and_redacted() { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let sensitive_body = format!( + "access_token:leaked-token exact bearer test-token https://sensitive.example/path {}", + "x".repeat(TEAMS_ERROR_BODY_LIMIT * 2) + ); + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with(ResponseTemplate::new(500).set_body_string(sensitive_body)) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let outcome = adapter + .send_activity_outcome(&server.uri(), "conversation-1", "hello", None) + .await; + let WriteOutcome::Unknown { code, message } = outcome else { + panic!("HTTP 500 must preserve ambiguous delivery") + }; + assert_eq!(code, "connector_server_error"); + assert!(message.contains("500")); + assert!(message.contains("[truncated]")); + assert!(!message.contains("leaked-token")); + assert!(!message.contains("test-token")); + assert!(!message.contains("sensitive.example")); + assert!(message.len() <= TEAMS_ERROR_BODY_LIMIT + 256); + } + + #[tokio::test] + async fn connector_request_timeout_hides_service_url() { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(250))) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test_with_timeout( + make_http_test_config(&server), + Duration::from_millis(75), + ); + + let outcome = adapter + .send_activity_outcome(&server.uri(), "conversation-1", "hello", None) + .await; + let WriteOutcome::Unknown { code, message } = outcome else { + panic!("POST timeout must preserve ambiguous delivery") + }; + assert_eq!(code, "request_timeout"); + assert!(message.contains("timed out")); + assert!(!message.contains(&server.uri())); + } + + #[tokio::test] + async fn connector_classifies_rejection_and_retry_after() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _rejected = Mock::given(method("POST")) + .and(path("/v3/conversations/rejected/activities")) + .respond_with(ResponseTemplate::new(400).set_body_string("bad activity")) + .expect(1) + .mount_as_scoped(&server) + .await; + let _too_large = Mock::given(method("POST")) + .and(path("/v3/conversations/too-large/activities")) + .respond_with(ResponseTemplate::new(413).set_body_string("message too large")) + .expect(1) + .mount_as_scoped(&server) + .await; + let _rate_limited = Mock::given(method("POST")) + .and(path("/v3/conversations/rate-limited/activities")) + .respond_with( + ResponseTemplate::new(429) + .insert_header("retry-after", "2") + .set_body_string("slow down"), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + let rejected = adapter + .send_activity_outcome(&server.uri(), "rejected", "hello", None) + .await; + assert!(matches!( + rejected, + WriteOutcome::Rejected { + ref code, + retry_after_ms: None, + .. + } if code == "connector_rejected" + )); + + let too_large = adapter + .send_activity_outcome(&server.uri(), "too-large", "hello", None) + .await; + assert!(matches!( + too_large, + WriteOutcome::Rejected { + ref code, + retry_after_ms: None, + .. + } if code == "message_too_large" + )); + + let rate_limited = adapter + .send_activity_outcome(&server.uri(), "rate-limited", "hello", None) + .await; + assert!(matches!( + rate_limited, + WriteOutcome::Rejected { + ref code, + retry_after_ms: Some(2000), + .. + } if code == "rate_limited" + )); + Ok(()) + } + + // --- reply command dispatch --- + + #[tokio::test] + async fn unsupported_commands_never_fall_through_to_send_activity() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _no_post = Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount_as_scoped(&server) + .await; + + let mut config = make_config(vec![]); + config.oauth_endpoint = format!("{}/token", server.uri()); + let adapter = TeamsAdapter::new_for_test(config); + + for command in ["add_reaction", "remove_reaction"] { + let outcome = handle_reply(&make_reply(Some(command)), &adapter).await; + assert_eq!( + outcome, + WriteOutcome::Delivered { message_id: None }, + "reaction command {command} should be a no-op" + ); + } + + let registration = handle_reply(&make_reply(Some("register_conversation")), &adapter).await; + assert!(matches!( + registration, + WriteOutcome::Rejected { ref code, .. } + if code == "origin_route_not_found" + )); + + for command in ["create_topic", "future_unknown_command"] { + let outcome = handle_reply(&make_reply(Some(command)), &adapter).await; + assert!( + matches!( + outcome, + WriteOutcome::Rejected { ref code, ref message, .. } + if code == "unsupported_command" && message.contains(command) + ), + "outcome should identify unsupported command {command}: {outcome:?}" + ); + } + + for command in ["edit_message", "delete_message"] { + let outcome = handle_reply(&make_reply(Some(command)), &adapter).await; + assert!( + matches!( + outcome, + WriteOutcome::Rejected { ref code, .. } if code == "message_not_owned" + ), + "unowned command target must be rejected before HTTP: {outcome:?}" + ); + } + Ok(()) + } + + #[tokio::test] + async fn enabled_reactions_add_remove_and_accept_legacy_targets() -> anyhow::Result<()> { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _add = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/inbound-1/reactions/1f440_eyes", + )) + .and(header("content-length", "0")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount_as_scoped(&server) + .await; + let _remove = Mock::given(method("DELETE")) + .and(path( + "/v3/conversations/conversation-1/activities/inbound-1/reactions/1f440_eyes", + )) + .and(header("content-length", "0")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount_as_scoped(&server) + .await; + let _legacy_add = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/inbound-1/reactions/heart", + )) + .and(header("content-length", "0")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount_as_scoped(&server) + .await; + let attempts = Arc::new(AtomicUsize::new(0)); + let responder_attempts = attempts.clone(); + let _rate_limited = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/inbound-1/reactions/think", + )) + .and(header("content-length", "0")) + .respond_with(move |_request: &wiremock::Request| { + if responder_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(429).insert_header("retry-after", "0") + } else { + ResponseTemplate::new(204) + } + }) + .expect(2) + .mount_as_scoped(&server) + .await; + + let mut config = make_http_test_config(&server); + config.reactions_enabled = true; + let adapter = TeamsAdapter::new_for_test(config); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + + for command in ["add_reaction", "remove_reaction"] { + let mut reply = make_reply(Some(command)); + reply.target_message_id = Some("inbound-1".into()); + reply.content.text = "👀".into(); + assert_eq!( + handle_reply(&reply, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + } + + let mut legacy = make_reply(Some("add_reaction")); + legacy.reply_to = "inbound-1".into(); + legacy.content.text = "❤️".into(); + assert_eq!( + handle_reply(&legacy, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + + let mut rate_limited = make_reply(Some("add_reaction")); + rate_limited.target_message_id = Some("inbound-1".into()); + rate_limited.content.text = "🤔".into(); + assert_eq!( + handle_reply(&rate_limited, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + + let mut unknown = make_reply(Some("add_reaction")); + unknown.target_message_id = Some("untrusted-activity".into()); + unknown.content.text = "👀".into(); + assert!(matches!( + handle_reply(&unknown, &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "reaction_target_not_known" + )); + Ok(()) + } + + #[tokio::test] + async fn commandless_reply_still_sends_one_activity() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(serde_json::json!({ + "type": "message", + "from": { "id": "test-app" }, + "text": "reply text", + "textFormat": "markdown" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "activity-1"})), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + + let mut config = make_config(vec![]); + config.oauth_endpoint = format!("{}/token", server.uri()); + let adapter = TeamsAdapter::new_for_test(config); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + + let outcome = handle_reply(&make_reply(None), &adapter).await; + assert_eq!( + outcome, + WriteOutcome::Delivered { + message_id: Some("activity-1".into()) + } + ); + Ok(()) + } + + #[tokio::test] + async fn bot_owned_edit_and_delete_use_structured_target_and_legacy_fallback( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _send = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "bot-activity-1"})), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let _edit = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/bot-activity-1", + )) + .and(body_json(serde_json::json!({ + "type": "message", + "from": { "id": "test-app" }, + "text": "updated text", + "textFormat": "markdown" + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount_as_scoped(&server) + .await; + let _delete = Mock::given(method("DELETE")) + .and(path( + "/v3/conversations/conversation-1/activities/bot-activity-1", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount_as_scoped(&server) + .await; + + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + assert!(matches!( + handle_reply(&make_reply(None), &adapter).await, + WriteOutcome::Delivered { ref message_id } + if message_id.as_deref() == Some("bot-activity-1") + )); + + let mut structured_edit = make_reply(Some("edit_message")); + structured_edit.content.text = "updated text".into(); + structured_edit.target_message_id = Some("bot-activity-1".into()); + assert_eq!( + handle_reply(&structured_edit, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + + let mut legacy_edit = make_reply(Some("edit_message")); + legacy_edit.reply_to = "bot-activity-1".into(); + legacy_edit.content.text = "updated text".into(); + assert_eq!( + handle_reply(&legacy_edit, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + + let mut delete = make_reply(Some("delete_message")); + delete.target_message_id = Some("bot-activity-1".into()); + assert_eq!( + handle_reply(&delete, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + assert!(matches!( + handle_reply(&structured_edit, &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "message_not_owned" + )); + Ok(()) + } + + #[tokio::test] + async fn unknown_delete_outcome_preserves_ownership_for_later_reconciliation( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _send = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "bot-activity-1"})), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let _delete = Mock::given(method("DELETE")) + .and(path( + "/v3/conversations/conversation-1/activities/bot-activity-1", + )) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount_as_scoped(&server) + .await; + let _edit = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/bot-activity-1", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount_as_scoped(&server) + .await; + + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + assert!(matches!( + handle_reply(&make_reply(None), &adapter).await, + WriteOutcome::Delivered { .. } + )); + + let mut delete = make_reply(Some("delete_message")); + delete.target_message_id = Some("bot-activity-1".into()); + assert!(matches!( + handle_reply(&delete, &adapter).await, + WriteOutcome::Unknown { ref code, .. } if code == "connector_server_error" + )); + + let mut edit = make_reply(Some("edit_message")); + edit.target_message_id = Some("bot-activity-1".into()); + assert_eq!( + handle_reply(&edit, &adapter).await, + WriteOutcome::Delivered { message_id: None } + ); + Ok(()) + } + + #[tokio::test] + async fn inbound_or_cross_conversation_mutation_is_rejected_before_http() -> anyhow::Result<()> + { + let server = MockServer::start().await; + let _no_http = Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount_as_scoped(&server) + .await; + let _no_put = Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + + let mut inbound_target = make_reply(Some("edit_message")); + inbound_target.target_message_id = Some("inbound-1".into()); + assert!(matches!( + handle_reply(&inbound_target, &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "message_not_owned" + )); + + let mut wrong_conversation = inbound_target; + wrong_conversation.channel.id = "conversation-2".into(); + assert!(matches!( + handle_reply(&wrong_conversation, &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "target_scope_mismatch" + )); + Ok(()) + } + + #[tokio::test] + async fn mutation_outcomes_and_bounded_rate_limit_retry_are_explicit() -> anyhow::Result<()> { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _server_error = Mock::given(method("PUT")) + .and(path("/v3/conversations/server-error/activities/bot-1")) + .respond_with(ResponseTemplate::new(503).set_body_string("unavailable")) + .expect(1) + .mount_as_scoped(&server) + .await; + let _rejected = Mock::given(method("DELETE")) + .and(path("/v3/conversations/rejected/activities/bot-1")) + .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) + .expect(1) + .mount_as_scoped(&server) + .await; + let _long_rate_limit = Mock::given(method("DELETE")) + .and(path("/v3/conversations/long-rate-limit/activities/bot-1")) + .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "2")) + .expect(1) + .mount_as_scoped(&server) + .await; + let attempts = Arc::new(AtomicUsize::new(0)); + let responder_attempts = attempts.clone(); + let _rate_limited = Mock::given(method("PUT")) + .and(path("/v3/conversations/rate-limited/activities/bot-1")) + .respond_with(move |_request: &wiremock::Request| { + if responder_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(429).insert_header("retry-after", "0") + } else { + ResponseTemplate::new(200) + } + }) + .expect(2) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + assert!(matches!( + adapter + .update_activity_outcome(&server.uri(), "server-error", "bot-1", "updated") + .await, + WriteOutcome::Unknown { ref code, .. } if code == "connector_server_error" + )); + assert!(matches!( + adapter + .delete_activity_outcome(&server.uri(), "rejected", "bot-1") + .await, + WriteOutcome::Rejected { ref code, .. } if code == "authorization_rejected" + )); + assert!(matches!( + adapter + .delete_activity_outcome(&server.uri(), "long-rate-limit", "bot-1") + .await, + WriteOutcome::Rejected { + ref code, + retry_after_ms: Some(2000), + .. + } if code == "rate_limited" + )); + assert_eq!( + adapter + .update_activity_outcome(&server.uri(), "rate-limited", "bot-1", "updated") + .await, + WriteOutcome::Delivered { message_id: None } + ); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + Ok(()) + } + + #[tokio::test] + async fn mutation_timeout_is_unknown_and_not_retried() { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _update = Mock::given(method("PUT")) + .and(path("/v3/conversations/conversation-1/activities/bot-1")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(250))) + .expect(1) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test_with_timeout( + make_http_test_config(&server), + Duration::from_millis(75), + ); + + assert!(matches!( + adapter + .update_activity_outcome(&server.uri(), "conversation-1", "bot-1", "updated") + .await, + WriteOutcome::Unknown { ref code, .. } if code == "request_timeout" + )); + } + + #[tokio::test] + async fn conversation_write_shards_serialize_same_scope_without_blocking_others( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + adapter + .accept_route_for_test( + &server.uri(), + "event-1", + "tenant-1", + "conversation-1", + "inbound-1", + None, + ) + .await?; + let route_one = adapter + .ingress + .lock() + .await + .route_for_event("event-1", Instant::now()) + .ok_or_else(|| anyhow::anyhow!("first test route missing"))?; + + let mut other_conversation = 2usize; + let route_two = loop { + let conversation = format!("conversation-{other_conversation}"); + let event = format!("event-{other_conversation}"); + let inbound = format!("inbound-{other_conversation}"); + adapter + .accept_route_for_test( + &server.uri(), + &event, + "tenant-1", + &conversation, + &inbound, + None, + ) + .await?; + let candidate = adapter + .ingress + .lock() + .await + .route_for_event(&event, Instant::now()) + .ok_or_else(|| anyhow::anyhow!("second test route missing"))?; + if TeamsAdapter::conversation_write_shard(&candidate) + != TeamsAdapter::conversation_write_shard(&route_one) + { + break candidate; + } + other_conversation += 1; + if other_conversation > TEAMS_WRITE_SHARDS * 4 { + anyhow::bail!("failed to find a distinct conversation write shard"); + } + }; + + let first_guard = adapter.lock_conversation(&route_one).await; + assert!( + tokio::time::timeout( + Duration::from_millis(20), + adapter.lock_conversation(&route_one) + ) + .await + .is_err(), + "same conversation must wait for the active write" + ); + let other_guard = tokio::time::timeout( + Duration::from_millis(20), + adapter.lock_conversation(&route_two), + ) + .await + .map_err(|_| anyhow::anyhow!("different conversation was unnecessarily serialized"))?; + drop(other_guard); + drop(first_guard); + Ok(()) + } + + #[tokio::test] + async fn explicit_quote_is_scoped_and_unknown_target_falls_back_to_plain_send( + ) -> anyhow::Result<()> { + let server = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + let _quoted = Mock::given(method("POST")) + .and(path( + "/v3/conversations/conversation-1/activities/inbound-1", + )) + .and(body_json(serde_json::json!({ + "type": "message", + "from": { "id": "test-app" }, + "text": "reply text", + "textFormat": "markdown", + "replyToId": "inbound-1" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "quoted-1"})), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + let _plain = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .and(body_json(serde_json::json!({ + "type": "message", + "from": { "id": "test-app" }, + "text": "reply text", + "textFormat": "markdown" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "plain-1"})), + ) + .expect(1) + .mount_as_scoped(&server) + .await; + + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + accept_test_route( + &adapter, + &server.uri(), + "evt-1", + "inbound-1", + Some("root-1"), + ) + .await?; + + let mut quoted_reply = make_reply(None); + quoted_reply.quote_message_id = Some("inbound-1".into()); + assert_eq!( + handle_reply("ed_reply, &adapter).await, + WriteOutcome::Delivered { + message_id: Some("quoted-1".into()) + } + ); + + let mut unknown_quote = make_reply(None); + unknown_quote.quote_message_id = Some("activity-from-another-scope".into()); + assert_eq!( + handle_reply(&unknown_quote, &adapter).await, + WriteOutcome::Delivered { + message_id: Some("plain-1".into()) + } + ); + Ok(()) + } + + #[tokio::test] + async fn missing_or_cross_conversation_route_is_rejected_before_http() -> anyhow::Result<()> { + let server = MockServer::start().await; + let _no_http = Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount_as_scoped(&server) + .await; + let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); + + assert!(matches!( + handle_reply(&make_reply(None), &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "route_not_found" + )); + + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; + let mut mismatched = make_reply(None); + mismatched.channel.id = "conversation-2".into(); + assert!(matches!( + handle_reply(&mismatched, &adapter).await, + WriteOutcome::Rejected { ref code, .. } if code == "route_mismatch" + )); + Ok(()) + } + // --- TeamsConfig::from_env --- + #[test] + fn runtime_config_defaults_and_positive_overrides() -> anyhow::Result<()> { + let mut values = std::collections::HashMap::from([ + ("TEAMS_APP_ID", "app"), + ("TEAMS_APP_SECRET", "secret"), + ("TEAMS_DEDUPE_TTL_SECS", "42"), + ("TEAMS_ROUTE_TTL_SECS", "84"), + ("TEAMS_MAX_ROUTE_ENTRIES", "123"), + ("TEAMS_CONVERSATION_REGISTRY_PATH", "teams/registry.json"), + ("TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", "321"), + ("TEAMS_CONVERSATION_REGISTRY_TTL_SECS", "987"), + ]); + let config = TeamsConfig::from_reader(|key| values.get(key).map(ToString::to_string)) + .ok_or_else(|| anyhow::anyhow!("complete credentials should resolve"))?; + assert_eq!(config.dedupe_ttl_secs, 42); + assert_eq!(config.route_ttl_secs, 84); + assert_eq!(config.max_route_entries, 123); + assert_eq!( + config.conversation_registry_path.as_deref(), + Some("teams/registry.json") + ); + assert_eq!(config.conversation_registry_max_entries, 321); + assert_eq!(config.conversation_registry_ttl_secs, 987); + assert!(!config.reactions_enabled); + assert!(!config.inbound_attachments); + + values.insert("TEAMS_REACTIONS_ENABLED", "true"); + values.insert("TEAMS_INBOUND_ATTACHMENTS", "1"); + let config = TeamsConfig::from_reader(|key| values.get(key).map(ToString::to_string)) + .ok_or_else(|| anyhow::anyhow!("complete credentials should resolve"))?; + assert!(config.reactions_enabled); + assert!(config.inbound_attachments); + + values.insert("TEAMS_DEDUPE_TTL_SECS", "0"); + values.insert("TEAMS_ROUTE_TTL_SECS", "invalid"); + values.insert("TEAMS_MAX_ROUTE_ENTRIES", "0"); + values.insert("TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", "0"); + values.insert("TEAMS_CONVERSATION_REGISTRY_TTL_SECS", "invalid"); + values.insert("TEAMS_REACTIONS_ENABLED", "invalid"); + values.insert("TEAMS_INBOUND_ATTACHMENTS", "invalid"); + let config = TeamsConfig::from_reader(|key| values.get(key).map(ToString::to_string)) + .ok_or_else(|| anyhow::anyhow!("complete credentials should resolve"))?; + assert_eq!(config.dedupe_ttl_secs, DEFAULT_DEDUPE_TTL_SECS); + assert_eq!(config.route_ttl_secs, DEFAULT_ROUTE_TTL_SECS); + assert_eq!(config.max_route_entries, DEFAULT_MAX_ROUTE_ENTRIES); + assert_eq!( + config.conversation_registry_max_entries, + DEFAULT_CONVERSATION_REGISTRY_MAX_ENTRIES + ); + assert_eq!( + config.conversation_registry_ttl_secs, + DEFAULT_CONVERSATION_REGISTRY_TTL_SECS + ); + assert!(!config.reactions_enabled); + assert!(!config.inbound_attachments); + Ok(()) + } + #[test] fn config_from_env_returns_none_without_vars() { // Ensure the env vars are not set (they shouldn't be in test) diff --git a/crates/openab-gateway/src/adapters/teams_ingress.rs b/crates/openab-gateway/src/adapters/teams_ingress.rs new file mode 100644 index 000000000..a826a0352 --- /dev/null +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -0,0 +1,1250 @@ +use reqwest::Url; +use std::collections::HashMap; +use std::time::{Duration, Instant}; +use tokio::sync::watch; +use tracing::warn; + +pub(super) const DEFAULT_DEDUPE_TTL_SECS: u64 = 10 * 60; +pub(super) const DEFAULT_ROUTE_TTL_SECS: u64 = 60 * 60; +pub(super) const DEFAULT_MAX_ROUTE_ENTRIES: usize = 10_000; +pub(super) const TEAMS_ATTACHMENT_AGGREGATE_MAX_BYTES: u64 = 20 * 1024 * 1024; + +const PUBLISHING_STALE_TTL: Duration = Duration::from_secs(30); +const PUBLISH_WAIT_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) struct TeamsRouteKey { + pub(super) app_id: String, + tenant_id: String, + conversation_id: String, + activity_id: String, +} + +impl TeamsRouteKey { + pub(super) fn new( + app_id: impl Into, + tenant_id: impl Into, + conversation_id: impl Into, + activity_id: impl Into, + ) -> Self { + Self { + app_id: app_id.into(), + tenant_id: tenant_id.into(), + conversation_id: conversation_id.into(), + activity_id: activity_id.into(), + } + } + + fn with_activity_id(&self, activity_id: impl Into) -> Self { + Self { + app_id: self.app_id.clone(), + tenant_id: self.tenant_id.clone(), + conversation_id: self.conversation_id.clone(), + activity_id: activity_id.into(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum TeamsAttachmentSourceKind { + InlineImage, + PersonalFileImage, + PersonalTextFile, +} + +#[derive(Clone)] +pub(super) struct TeamsAttachmentSource { + pub(super) kind: TeamsAttachmentSourceKind, + pub(super) url: Url, + pub(super) service_origin: Url, + pub(super) attachment_type: String, + pub(super) filename: String, + pub(super) mime_type: String, + pub(super) max_bytes: u64, +} + +pub(super) struct ClaimedTeamsAttachment { + pub(super) source: TeamsAttachmentSource, + pub(super) reserved_bytes: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum AttachmentLookupError { + RouteNotFound, + ConversationMismatch, + ReferenceNotFound, + AggregateLimitExceeded, +} + +/// Gateway-local routing material for one authenticated Teams activity. +/// +/// The service URL is intentionally kept out of the wire schema and logging. +/// Outbound Teams sends consume this route by `event_id`; ingress owns its +/// validation, bounds, expiry, and duplicate-safe publication. +#[allow(dead_code)] // authenticated scope fields are retained for later typed routing/ownership +#[derive(Clone)] +pub(super) struct TeamsIngressRoute { + pub(super) key: TeamsRouteKey, + pub(super) event_id: String, + pub(super) tenant_id: String, + pub(super) bot_framework_channel_id: String, + pub(super) conversation_id: String, + pub(super) conversation_type: String, + pub(super) inbound_activity_id: String, + pub(super) reply_chain_root_id: Option, + pub(super) service_url: Url, + pub(super) team_id: Option, + pub(super) channel_id: Option, + pub(super) attachment_sources: HashMap, + pub(super) attachment_materialized_bytes: u64, + pub(super) created_at: Instant, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PublishState { + Publishing, + Accepted, + Failed, +} + +struct DedupeEntry { + state: PublishState, + event_id: String, + updated_at: Instant, + completion: watch::Sender, +} + +struct OwnedActivityEntry { + route: TeamsIngressRoute, + created_at: Instant, +} + +pub(super) enum PublishReservation { + Owner, + AcceptedDuplicate, + PublishingDuplicate(watch::Receiver), + AtCapacity, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RouteLookupError { + NotFound, + ConversationMismatch, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum OwnershipLookupError { + NotOwned, + OriginRouteNotFound, + ConversationMismatch, + AmbiguousScope, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ReactionLookupError { + TargetNotKnown, + OriginRouteNotFound, + ConversationMismatch, + AmbiguousScope, +} + +#[derive(Debug, Default, Eq, PartialEq)] +pub(crate) struct TeamsIngressCleanupStats { + pub(crate) routes_removed: usize, + pub(crate) dedupe_entries_removed: usize, + pub(crate) stale_publications_removed: usize, + pub(crate) owned_activities_removed: usize, +} + +/// Process-local, bounded Teams route, dedupe, and bot-owned activity state. +/// +/// This is deliberately not a durable queue and does not provide cross-replica +/// idempotency. A per-key Publishing state plus completion channel ensures +/// concurrent retries observe the owner's local enqueue result. The ownership +/// index allows edit/delete only for activities created by this process. +pub(super) struct TeamsIngressRegistry { + routes_by_event: HashMap, + event_by_key: HashMap, + dedupe: HashMap, + owned: HashMap, + dedupe_ttl: Duration, + route_ttl: Duration, + max_entries: usize, +} + +impl TeamsIngressRegistry { + pub(super) fn new(dedupe_ttl: Duration, route_ttl: Duration, max_entries: usize) -> Self { + Self { + routes_by_event: HashMap::new(), + event_by_key: HashMap::new(), + dedupe: HashMap::new(), + owned: HashMap::new(), + dedupe_ttl, + route_ttl, + max_entries: max_entries.max(1), + } + } + + pub(super) fn reserve( + &mut self, + key: TeamsRouteKey, + event_id: String, + now: Instant, + ) -> PublishReservation { + self.cleanup(now); + + if let Some(entry) = self.dedupe.get(&key) { + return match entry.state { + PublishState::Accepted => PublishReservation::AcceptedDuplicate, + PublishState::Publishing => { + PublishReservation::PublishingDuplicate(entry.completion.subscribe()) + } + PublishState::Failed => { + // Failed entries are normally removed immediately. Treat a + // defensive leftover as vacant instead of suppressing retry. + self.dedupe.remove(&key); + self.reserve(key, event_id, now) + } + }; + } + + if self.dedupe.len() >= self.max_entries && !self.evict_oldest_accepted_dedupe() { + return PublishReservation::AtCapacity; + } + + let (completion, _) = watch::channel(PublishState::Publishing); + self.dedupe.insert( + key, + DedupeEntry { + state: PublishState::Publishing, + event_id, + updated_at: now, + completion, + }, + ); + PublishReservation::Owner + } + + pub(super) fn accept( + &mut self, + key: &TeamsRouteKey, + event_id: &str, + route: TeamsIngressRoute, + now: Instant, + ) -> bool { + let Some(entry) = self.dedupe.get_mut(key) else { + return false; + }; + if entry.state != PublishState::Publishing || entry.event_id != event_id { + return false; + } + + entry.state = PublishState::Accepted; + entry.updated_at = now; + entry.completion.send_replace(PublishState::Accepted); + self.insert_route(route); + true + } + + pub(super) fn fail(&mut self, key: &TeamsRouteKey, event_id: &str) { + let matches_owner = self + .dedupe + .get(key) + .is_some_and(|entry| entry.event_id == event_id); + if !matches_owner { + return; + } + if let Some(entry) = self.dedupe.remove(key) { + entry.completion.send_replace(PublishState::Failed); + self.remove_route(event_id); + } + } + + pub(super) fn cleanup(&mut self, now: Instant) -> TeamsIngressCleanupStats { + let expired_route_ids: Vec = self + .routes_by_event + .iter() + .filter(|(_, route)| now.saturating_duration_since(route.created_at) >= self.route_ttl) + .map(|(event_id, _)| event_id.clone()) + .collect(); + for event_id in &expired_route_ids { + self.remove_route(event_id); + } + + let expired_dedupe_keys: Vec = self + .dedupe + .iter() + .filter(|(_, entry)| match entry.state { + PublishState::Accepted => { + now.saturating_duration_since(entry.updated_at) >= self.dedupe_ttl + } + PublishState::Publishing => { + now.saturating_duration_since(entry.updated_at) >= PUBLISHING_STALE_TTL + } + PublishState::Failed => true, + }) + .map(|(key, _)| key.clone()) + .collect(); + + let mut stale_publications_removed = 0; + for key in &expired_dedupe_keys { + if let Some(entry) = self.dedupe.remove(key) { + if entry.state == PublishState::Publishing { + stale_publications_removed += 1; + entry.completion.send_replace(PublishState::Failed); + } + } + } + + let expired_owned_keys: Vec = self + .owned + .iter() + .filter(|(_, entry)| now.saturating_duration_since(entry.created_at) >= self.route_ttl) + .map(|(key, _)| key.clone()) + .collect(); + for key in &expired_owned_keys { + self.owned.remove(key); + } + + TeamsIngressCleanupStats { + routes_removed: expired_route_ids.len(), + dedupe_entries_removed: expired_dedupe_keys.len(), + stale_publications_removed, + owned_activities_removed: expired_owned_keys.len(), + } + } + + pub(super) fn route_for_reply( + &mut self, + event_id: &str, + conversation_id: &str, + requested_quote: Option<&str>, + now: Instant, + ) -> Result<(TeamsIngressRoute, Option), RouteLookupError> { + self.cleanup(now); + let route = self + .routes_by_event + .get(event_id) + .cloned() + .ok_or(RouteLookupError::NotFound)?; + if route.conversation_id != conversation_id { + return Err(RouteLookupError::ConversationMismatch); + } + + // A quote is safe only when its activity was authenticated in the same + // app/tenant/conversation scope. The current activity and its declared + // reply-chain root are already authenticated routing material; older + // activities must still exist in the bounded route index. + let quote_activity_id = requested_quote + .filter(|activity_id| !activity_id.trim().is_empty()) + .filter(|activity_id| { + route.inbound_activity_id == **activity_id + || route.reply_chain_root_id.as_deref() == Some(*activity_id) + || self + .event_by_key + .get(&route.key.with_activity_id(*activity_id)) + .and_then(|known_event_id| self.routes_by_event.get(known_event_id)) + .is_some() + }) + .map(str::to_owned); + + Ok((route, quote_activity_id)) + } + + pub(super) fn claim_attachment( + &mut self, + event_id: &str, + conversation_id: &str, + reference: &str, + now: Instant, + ) -> Result { + self.cleanup(now); + let route = self + .routes_by_event + .get_mut(event_id) + .ok_or(AttachmentLookupError::RouteNotFound)?; + if route.conversation_id != conversation_id { + return Err(AttachmentLookupError::ConversationMismatch); + } + + let remaining = TEAMS_ATTACHMENT_AGGREGATE_MAX_BYTES + .saturating_sub(route.attachment_materialized_bytes); + if remaining == 0 { + return Err(AttachmentLookupError::AggregateLimitExceeded); + } + let source = route + .attachment_sources + .remove(reference) + .ok_or(AttachmentLookupError::ReferenceNotFound)?; + let reserved_bytes = source.max_bytes.min(remaining); + if reserved_bytes == 0 { + return Err(AttachmentLookupError::AggregateLimitExceeded); + } + route.attachment_materialized_bytes = route + .attachment_materialized_bytes + .saturating_add(reserved_bytes); + Ok(ClaimedTeamsAttachment { + source, + reserved_bytes, + }) + } + + pub(super) fn finish_attachment( + &mut self, + event_id: &str, + reserved_bytes: u64, + materialized_bytes: u64, + ) { + let Some(route) = self.routes_by_event.get_mut(event_id) else { + return; + }; + route.attachment_materialized_bytes = route + .attachment_materialized_bytes + .saturating_sub(reserved_bytes) + .saturating_add(materialized_bytes.min(reserved_bytes)); + } + + pub(super) fn route_for_reaction_target( + &mut self, + app_id: &str, + origin_event_id: Option<&str>, + conversation_id: &str, + activity_id: &str, + now: Instant, + ) -> Result { + self.cleanup(now); + + if let Some(origin_event_id) = origin_event_id { + if origin_event_id.is_empty() { + return Err(ReactionLookupError::OriginRouteNotFound); + } + let origin_route = self + .routes_by_event + .get(origin_event_id) + .cloned() + .ok_or(ReactionLookupError::OriginRouteNotFound)?; + if origin_route.conversation_id != conversation_id { + return Err(ReactionLookupError::ConversationMismatch); + } + if origin_route.key.app_id != app_id { + return Err(ReactionLookupError::TargetNotKnown); + } + if origin_route.inbound_activity_id == activity_id + || origin_route.reply_chain_root_id.as_deref() == Some(activity_id) + { + return Ok(origin_route); + } + + let target_key = origin_route.key.with_activity_id(activity_id); + if let Some(route) = self + .event_by_key + .get(&target_key) + .and_then(|event_id| self.routes_by_event.get(event_id)) + { + return Ok(route.clone()); + } + return self + .owned + .get(&target_key) + .map(|entry| entry.route.clone()) + .ok_or(ReactionLookupError::TargetNotKnown); + } + + let mut candidates = HashMap::::new(); + for route in self.routes_by_event.values().filter(|route| { + route.key.app_id == app_id + && route.conversation_id == conversation_id + && route.inbound_activity_id == activity_id + }) { + candidates.insert(route.key.clone(), route.clone()); + } + for (key, entry) in self.owned.iter().filter(|(key, _)| { + key.app_id == app_id + && key.conversation_id == conversation_id + && key.activity_id == activity_id + }) { + candidates.insert(key.clone(), entry.route.clone()); + } + + let mut candidates = candidates.into_values(); + let route = candidates + .next() + .ok_or(ReactionLookupError::TargetNotKnown)?; + if candidates.next().is_some() { + return Err(ReactionLookupError::AmbiguousScope); + } + Ok(route) + } + + pub(super) fn record_owned( + &mut self, + route: &TeamsIngressRoute, + activity_id: &str, + now: Instant, + ) { + self.cleanup(now); + let key = route.key.with_activity_id(activity_id); + if !self.owned.contains_key(&key) && self.owned.len() >= self.max_entries { + if let Some(oldest_key) = self + .owned + .iter() + .min_by_key(|(_, entry)| entry.created_at) + .map(|(key, _)| key.clone()) + { + self.owned.remove(&oldest_key); + warn!( + max_entries = self.max_entries, + "teams outbound ownership cache evicted its oldest entry at capacity" + ); + } + } + // Ownership needs the authenticated Connector route but never the + // presigned attachment URLs. Do not duplicate attachment capabilities + // into every bot-owned activity entry. + let mut owned_route = route.clone(); + owned_route.attachment_sources.clear(); + owned_route.attachment_materialized_bytes = 0; + self.owned.insert( + key, + OwnedActivityEntry { + route: owned_route, + created_at: now, + }, + ); + } + + pub(super) fn owned_route_for_target( + &mut self, + app_id: &str, + origin_event_id: Option<&str>, + conversation_id: &str, + activity_id: &str, + now: Instant, + ) -> Result { + self.cleanup(now); + + if let Some(origin_event_id) = origin_event_id { + if origin_event_id.is_empty() { + return Err(OwnershipLookupError::OriginRouteNotFound); + } + let Some(origin_route) = self.routes_by_event.get(origin_event_id) else { + return Err(OwnershipLookupError::OriginRouteNotFound); + }; + if origin_route.conversation_id != conversation_id { + return Err(OwnershipLookupError::ConversationMismatch); + } + return self + .owned + .get(&origin_route.key.with_activity_id(activity_id)) + .map(|entry| entry.route.clone()) + .ok_or(OwnershipLookupError::NotOwned); + } + + let mut candidates = self.owned.iter().filter(|(key, _)| { + key.app_id == app_id + && key.conversation_id == conversation_id + && key.activity_id == activity_id + }); + let Some((_, candidate)) = candidates.next() else { + return Err(OwnershipLookupError::NotOwned); + }; + if candidates.next().is_some() { + return Err(OwnershipLookupError::AmbiguousScope); + } + Ok(candidate.route.clone()) + } + + pub(super) fn owned_route_for_exact_target( + &mut self, + app_id: &str, + tenant_id: &str, + conversation_id: &str, + activity_id: &str, + now: Instant, + ) -> Result { + self.cleanup(now); + let key = TeamsRouteKey::new(app_id, tenant_id, conversation_id, activity_id); + self.owned + .get(&key) + .map(|entry| entry.route.clone()) + .ok_or(OwnershipLookupError::NotOwned) + } + + pub(super) fn remove_owned(&mut self, route: &TeamsIngressRoute, activity_id: &str) -> bool { + self.owned + .remove(&route.key.with_activity_id(activity_id)) + .is_some() + } + + pub(super) fn route_for_registration( + &mut self, + event_id: &str, + conversation_id: &str, + now: Instant, + ) -> Result { + self.cleanup(now); + let route = self + .routes_by_event + .get(event_id) + .ok_or(RouteLookupError::NotFound)?; + if route.conversation_id != conversation_id { + return Err(RouteLookupError::ConversationMismatch); + } + Ok(route.clone()) + } + + #[cfg(test)] + pub(super) fn route_for_event( + &mut self, + event_id: &str, + now: Instant, + ) -> Option { + self.cleanup(now); + self.routes_by_event.get(event_id).cloned() + } + + #[cfg(test)] + pub(super) fn contains_dedupe_key(&self, key: &TeamsRouteKey) -> bool { + self.dedupe.contains_key(key) + } + + fn insert_route(&mut self, route: TeamsIngressRoute) { + if let Some(previous_event_id) = self.event_by_key.remove(&route.key) { + self.routes_by_event.remove(&previous_event_id); + } + + if self.routes_by_event.len() >= self.max_entries { + if let Some(oldest_event_id) = self + .routes_by_event + .iter() + .min_by_key(|(_, existing)| existing.created_at) + .map(|(event_id, _)| event_id.clone()) + { + self.remove_route(&oldest_event_id); + warn!( + max_entries = self.max_entries, + "teams ingress route cache evicted its oldest entry at capacity" + ); + } + } + + self.event_by_key + .insert(route.key.clone(), route.event_id.clone()); + self.routes_by_event.insert(route.event_id.clone(), route); + } + + fn remove_route(&mut self, event_id: &str) { + if let Some(route) = self.routes_by_event.remove(event_id) { + if self.event_by_key.get(&route.key).map(String::as_str) == Some(event_id) { + self.event_by_key.remove(&route.key); + } + } + } + + fn evict_oldest_accepted_dedupe(&mut self) -> bool { + let Some(oldest_key) = self + .dedupe + .iter() + .filter(|(_, entry)| entry.state == PublishState::Accepted) + .min_by_key(|(_, entry)| entry.updated_at) + .map(|(key, _)| key.clone()) + else { + return false; + }; + + self.dedupe.remove(&oldest_key); + warn!( + max_entries = self.max_entries, + "teams ingress dedupe cache evicted its oldest accepted entry at capacity" + ); + true + } +} + +pub(super) async fn wait_for_publish( + mut completion: watch::Receiver, +) -> PublishState { + if *completion.borrow() != PublishState::Publishing { + return *completion.borrow(); + } + + match tokio::time::timeout(PUBLISH_WAIT_TIMEOUT, completion.changed()).await { + Ok(Ok(())) => *completion.borrow(), + Ok(Err(_)) | Err(_) => PublishState::Failed, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(index: usize) -> TeamsRouteKey { + TeamsRouteKey::new("app", "tenant", "conversation", format!("activity-{index}")) + } + + fn attachment_source(max_bytes: u64) -> anyhow::Result { + Ok(TeamsAttachmentSource { + kind: TeamsAttachmentSourceKind::PersonalTextFile, + url: Url::parse("https://tenant.sharepoint.com/download?opaque=1")?, + service_origin: Url::parse("https://smba.trafficmanager.net/emea/")?, + attachment_type: "text_file".into(), + filename: "notes.txt".into(), + mime_type: "text/plain; charset=utf-8".into(), + max_bytes, + }) + } + + fn route( + key: TeamsRouteKey, + event_id: &str, + created_at: Instant, + ) -> anyhow::Result { + Ok(TeamsIngressRoute { + tenant_id: "tenant".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation".into(), + conversation_type: "personal".into(), + inbound_activity_id: key.activity_id.clone(), + reply_chain_root_id: None, + service_url: Url::parse("https://smba.trafficmanager.net/emea/")?, + team_id: None, + channel_id: None, + attachment_sources: HashMap::new(), + attachment_materialized_bytes: 0, + key, + event_id: event_id.into(), + created_at, + }) + } + + #[test] + fn accepted_duplicate_is_suppressed_until_ttl_expires() -> anyhow::Result<()> { + let base = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(10), Duration::from_secs(60), 10); + let route_key = key(1); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), base), + PublishReservation::Owner + )); + assert!(registry.accept( + &route_key, + "event-1", + route(route_key.clone(), "event-1", base)?, + base + )); + assert!(matches!( + registry.reserve( + route_key.clone(), + "duplicate-event".into(), + base + Duration::from_secs(9) + ), + PublishReservation::AcceptedDuplicate + )); + assert!(matches!( + registry.reserve( + route_key, + "event-after-ttl".into(), + base + Duration::from_secs(10) + ), + PublishReservation::Owner + )); + Ok(()) + } + + #[tokio::test] + async fn publishing_duplicate_observes_the_owner_result() -> anyhow::Result<()> { + let base = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let route_key = key(1); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), base), + PublishReservation::Owner + )); + let waiter = match registry.reserve( + route_key.clone(), + "duplicate-event".into(), + base + Duration::from_millis(1), + ) { + PublishReservation::PublishingDuplicate(waiter) => waiter, + _ => panic!("duplicate must wait for the publishing owner"), + }; + assert!(registry.accept( + &route_key, + "event-1", + route(route_key.clone(), "event-1", base)?, + base + Duration::from_millis(2) + )); + assert_eq!(wait_for_publish(waiter).await, PublishState::Accepted); + Ok(()) + } + + #[tokio::test] + async fn publish_failure_returns_to_vacant_and_wakes_duplicates() { + let base = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let route_key = key(1); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), base), + PublishReservation::Owner + )); + let waiter = match registry.reserve( + route_key.clone(), + "duplicate-event".into(), + base + Duration::from_millis(1), + ) { + PublishReservation::PublishingDuplicate(waiter) => waiter, + _ => panic!("duplicate must wait for the publishing owner"), + }; + registry.fail(&route_key, "event-1"); + assert_eq!(wait_for_publish(waiter).await, PublishState::Failed); + assert!(!registry.contains_dedupe_key(&route_key)); + assert!(matches!( + registry.reserve(route_key, "retry-event".into(), base), + PublishReservation::Owner + )); + } + + #[test] + fn failed_local_enqueue_rolls_back_a_provisional_route() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let route_key = key(1); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), now), + PublishReservation::Owner + )); + assert!(registry.accept( + &route_key, + "event-1", + route(route_key.clone(), "event-1", now)?, + now + )); + + registry.fail(&route_key, "event-1"); + assert!(!registry.contains_dedupe_key(&route_key)); + assert!(registry.route_for_event("event-1", now).is_none()); + Ok(()) + } + + #[test] + fn route_and_dedupe_state_are_bounded_and_expire_independently() -> anyhow::Result<()> { + let base = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(10), Duration::from_secs(20), 2); + + for index in 0..3 { + let route_key = key(index); + let event_id = format!("event-{index}"); + let now = base + Duration::from_secs(index as u64); + assert!(matches!( + registry.reserve(route_key.clone(), event_id.clone(), now), + PublishReservation::Owner + )); + assert!(registry.accept( + &route_key, + &event_id, + route(route_key.clone(), &event_id, now)?, + now + )); + } + + assert!(registry.route_for_event("event-0", base).is_none()); + assert!(registry.route_for_event("event-2", base).is_some()); + let stats = registry.cleanup(base + Duration::from_secs(22)); + assert_eq!(stats.routes_removed, 2); + assert_eq!(stats.dedupe_entries_removed, 2); + Ok(()) + } + + #[test] + fn same_activity_id_in_different_tenants_or_conversations_does_not_collide() { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let keys = [ + TeamsRouteKey::new("app", "tenant-1", "conversation-1", "activity"), + TeamsRouteKey::new("app", "tenant-2", "conversation-1", "activity"), + TeamsRouteKey::new("app", "tenant-1", "conversation-2", "activity"), + TeamsRouteKey::new("other-app", "tenant-1", "conversation-1", "activity"), + ]; + + for (index, route_key) in keys.into_iter().enumerate() { + assert!(matches!( + registry.reserve(route_key, format!("event-{index}"), now), + PublishReservation::Owner + )); + } + } + + #[test] + fn reply_lookup_uses_event_scope_and_validates_quote_activity() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + + for index in 0..2 { + let route_key = key(index); + let event_id = format!("event-{index}"); + assert!(matches!( + registry.reserve(route_key.clone(), event_id.clone(), now), + PublishReservation::Owner + )); + let mut accepted_route = route(route_key.clone(), &event_id, now)?; + if index == 1 { + accepted_route.reply_chain_root_id = Some("root-activity".into()); + } + assert!(registry.accept(&route_key, &event_id, accepted_route, now)); + } + + for (route_key, event_id, tenant_id, conversation_id) in [ + ( + TeamsRouteKey::new("app", "other-tenant", "conversation", "cross-tenant"), + "event-cross-tenant", + "other-tenant", + "conversation", + ), + ( + TeamsRouteKey::new("app", "tenant", "other-conversation", "cross-conversation"), + "event-cross-conversation", + "tenant", + "other-conversation", + ), + ] { + assert!(matches!( + registry.reserve(route_key.clone(), event_id.into(), now), + PublishReservation::Owner + )); + let mut accepted_route = route(route_key.clone(), event_id, now)?; + accepted_route.tenant_id = tenant_id.into(); + accepted_route.conversation_id = conversation_id.into(); + assert!(registry.accept(&route_key, event_id, accepted_route, now)); + } + + let (_, current_quote) = registry + .route_for_reply("event-1", "conversation", Some("activity-1"), now) + .map_err(|error| anyhow::anyhow!("unexpected route error: {error:?}"))?; + assert_eq!(current_quote.as_deref(), Some("activity-1")); + + let (_, root_quote) = registry + .route_for_reply("event-1", "conversation", Some("root-activity"), now) + .map_err(|error| anyhow::anyhow!("unexpected route error: {error:?}"))?; + assert_eq!(root_quote.as_deref(), Some("root-activity")); + + let (_, prior_quote) = registry + .route_for_reply("event-1", "conversation", Some("activity-0"), now) + .map_err(|error| anyhow::anyhow!("unexpected route error: {error:?}"))?; + assert_eq!(prior_quote.as_deref(), Some("activity-0")); + + for unknown_target in ["unknown", "cross-tenant", "cross-conversation"] { + let (_, unknown_quote) = registry + .route_for_reply("event-1", "conversation", Some(unknown_target), now) + .map_err(|error| anyhow::anyhow!("unexpected route error: {error:?}"))?; + assert!( + unknown_quote.is_none(), + "quote target {unknown_target} must not cross route scope" + ); + } + assert!(matches!( + registry.route_for_reply("event-1", "other-conversation", None, now), + Err(RouteLookupError::ConversationMismatch) + )); + assert!(matches!( + registry.route_for_reply("missing-event", "conversation", None, now), + Err(RouteLookupError::NotFound) + )); + Ok(()) + } + + #[test] + fn reaction_targets_require_authenticated_scope_and_legacy_uniqueness() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let route_key = key(1); + let mut origin_route = route(route_key.clone(), "event-1", now)?; + origin_route.reply_chain_root_id = Some("root-1".into()); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), now), + PublishReservation::Owner + )); + assert!(registry.accept(&route_key, "event-1", origin_route.clone(), now)); + registry.record_owned(&origin_route, "bot-1", now); + + for target in ["activity-1", "root-1", "bot-1"] { + let resolved = registry + .route_for_reaction_target("app", Some("event-1"), "conversation", target, now) + .map_err(|error| anyhow::anyhow!("unexpected reaction route error: {error:?}"))?; + assert_eq!(resolved.tenant_id, "tenant"); + } + assert!(matches!( + registry.route_for_reaction_target( + "app", + Some("event-1"), + "conversation", + "unknown", + now + ), + Err(ReactionLookupError::TargetNotKnown) + )); + assert!(matches!( + registry.route_for_reaction_target( + "app", + Some("event-1"), + "other-conversation", + "activity-1", + now + ), + Err(ReactionLookupError::ConversationMismatch) + )); + assert!(registry + .route_for_reaction_target("app", None, "conversation", "activity-1", now) + .is_ok()); + + let other_key = TeamsRouteKey::new("app", "other-tenant", "conversation", "activity-1"); + let mut other_route = route(other_key.clone(), "event-2", now)?; + other_route.tenant_id = "other-tenant".into(); + assert!(matches!( + registry.reserve(other_key.clone(), "event-2".into(), now), + PublishReservation::Owner + )); + assert!(registry.accept(&other_key, "event-2", other_route, now)); + assert!(matches!( + registry.route_for_reaction_target("app", None, "conversation", "activity-1", now), + Err(ReactionLookupError::AmbiguousScope) + )); + Ok(()) + } + + #[test] + fn bot_owned_activity_index_is_bounded_enforced_and_expiring() -> anyhow::Result<()> { + let base = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(10), 2); + let route_key = key(1); + let mut owned_route = route(route_key.clone(), "event-1", base)?; + owned_route + .attachment_sources + .insert("secret-ref".into(), attachment_source(1024)?); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), base), + PublishReservation::Owner + )); + assert!(registry.accept(&route_key, "event-1", owned_route.clone(), base)); + + registry.record_owned(&owned_route, "bot-0", base); + registry.record_owned(&owned_route, "bot-1", base + Duration::from_secs(1)); + registry.record_owned(&owned_route, "bot-2", base + Duration::from_secs(2)); + assert!(registry + .owned + .get(&route_key.with_activity_id("bot-1")) + .is_some_and(|entry| entry.route.attachment_sources.is_empty())); + + assert!(matches!( + registry.owned_route_for_target( + "app", + Some("event-1"), + "conversation", + "bot-0", + base + Duration::from_secs(2) + ), + Err(OwnershipLookupError::NotOwned) + )); + assert!(registry + .owned_route_for_target( + "app", + Some("event-1"), + "conversation", + "bot-1", + base + Duration::from_secs(2) + ) + .is_ok()); + assert!(matches!( + registry.owned_route_for_target( + "app", + Some("missing-event"), + "conversation", + "bot-1", + base + Duration::from_secs(2) + ), + Err(OwnershipLookupError::OriginRouteNotFound) + )); + assert!(matches!( + registry.owned_route_for_target( + "app", + Some("event-1"), + "conversation", + "activity-1", + base + Duration::from_secs(2) + ), + Err(OwnershipLookupError::NotOwned) + )); + assert!(registry.remove_owned(&owned_route, "bot-1")); + assert!(!registry.remove_owned(&owned_route, "bot-1")); + + let stats = registry.cleanup(base + Duration::from_secs(12)); + assert_eq!(stats.owned_activities_removed, 1); + assert!(matches!( + registry.owned_route_for_target( + "app", + None, + "conversation", + "bot-2", + base + Duration::from_secs(12) + ), + Err(OwnershipLookupError::NotOwned) + )); + Ok(()) + } + + #[test] + fn legacy_owned_target_rejects_ambiguous_tenant_scope() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + + for (tenant, inbound, event_id) in [ + ("tenant-1", "inbound-1", "event-1"), + ("tenant-2", "inbound-2", "event-2"), + ] { + let route_key = TeamsRouteKey::new("app", tenant, "conversation", inbound); + let mut owned_route = route(route_key.clone(), event_id, now)?; + owned_route.tenant_id = tenant.into(); + assert!(matches!( + registry.reserve(route_key.clone(), event_id.into(), now), + PublishReservation::Owner + )); + assert!(registry.accept(&route_key, event_id, owned_route.clone(), now)); + registry.record_owned(&owned_route, "bot-shared-id", now); + } + + assert!(matches!( + registry.owned_route_for_target("app", None, "conversation", "bot-shared-id", now), + Err(OwnershipLookupError::AmbiguousScope) + )); + let tenant_one = registry + .owned_route_for_target("app", Some("event-1"), "conversation", "bot-shared-id", now) + .map_err(|error| anyhow::anyhow!("unexpected ownership error: {error:?}"))?; + assert_eq!(tenant_one.tenant_id, "tenant-1"); + assert!(matches!( + registry.owned_route_for_target( + "app", + Some("event-1"), + "other-conversation", + "bot-shared-id", + now + ), + Err(OwnershipLookupError::ConversationMismatch) + )); + Ok(()) + } + + #[test] + fn attachment_claim_is_route_scoped_single_use_and_budgeted() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 10); + let route_key = key(1); + let mut accepted_route = route(route_key.clone(), "event-1", now)?; + for reference in ["ref-1", "ref-2", "ref-3"] { + accepted_route + .attachment_sources + .insert(reference.into(), attachment_source(10 * 1024 * 1024)?); + } + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), now), + PublishReservation::Owner + )); + assert!(registry.accept(&route_key, "event-1", accepted_route, now)); + + assert!(matches!( + registry.claim_attachment("event-1", "other", "ref-1", now), + Err(AttachmentLookupError::ConversationMismatch) + )); + let first = registry + .claim_attachment("event-1", "conversation", "ref-1", now) + .map_err(|error| anyhow::anyhow!("unexpected attachment error: {error:?}"))?; + assert_eq!(first.reserved_bytes, 10 * 1024 * 1024); + assert_eq!( + first.source.kind, + TeamsAttachmentSourceKind::PersonalTextFile + ); + assert!(matches!( + registry.claim_attachment("event-1", "conversation", "ref-1", now), + Err(AttachmentLookupError::ReferenceNotFound) + )); + registry.finish_attachment("event-1", first.reserved_bytes, first.reserved_bytes); + + let second = registry + .claim_attachment("event-1", "conversation", "ref-2", now) + .map_err(|error| anyhow::anyhow!("unexpected attachment error: {error:?}"))?; + registry.finish_attachment("event-1", second.reserved_bytes, second.reserved_bytes); + assert!(matches!( + registry.claim_attachment("event-1", "conversation", "ref-3", now), + Err(AttachmentLookupError::AggregateLimitExceeded) + )); + assert!(matches!( + registry.claim_attachment("missing", "conversation", "ref-3", now), + Err(AttachmentLookupError::RouteNotFound) + )); + Ok(()) + } + + #[test] + fn expired_route_cannot_be_used_for_reply() -> anyhow::Result<()> { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(10), 10); + let route_key = key(1); + assert!(matches!( + registry.reserve(route_key.clone(), "event-1".into(), now), + PublishReservation::Owner + )); + assert!(registry.accept( + &route_key, + "event-1", + route(route_key.clone(), "event-1", now)?, + now + )); + assert!(matches!( + registry.route_for_registration("event-1", "other", now), + Err(RouteLookupError::ConversationMismatch) + )); + assert!(matches!( + registry.route_for_registration( + "event-1", + "conversation", + now + Duration::from_secs(10) + ), + Err(RouteLookupError::NotFound) + )); + assert!(matches!( + registry.route_for_reply( + "event-1", + "conversation", + None, + now + Duration::from_secs(10) + ), + Err(RouteLookupError::NotFound) + )); + Ok(()) + } + + #[test] + fn capacity_rejects_when_every_dedupe_entry_is_publishing() { + let now = Instant::now(); + let mut registry = + TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(60), 1); + assert!(matches!( + registry.reserve(key(1), "event-1".into(), now), + PublishReservation::Owner + )); + assert!(matches!( + registry.reserve(key(2), "event-2".into(), now), + PublishReservation::AtCapacity + )); + } +} diff --git a/crates/openab-gateway/src/adapters/teams_registry.rs b/crates/openab-gateway/src/adapters/teams_registry.rs new file mode 100644 index 000000000..3c3bfb415 --- /dev/null +++ b/crates/openab-gateway/src/adapters/teams_registry.rs @@ -0,0 +1,1346 @@ +use super::teams_ingress::TeamsIngressRoute; +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; + +const REGISTRY_SCHEMA: &str = "openab.teams.conversation_registry.v1"; +const REGISTRY_VERSION: u32 = 1; +pub(super) const DEFAULT_CONVERSATION_REGISTRY_MAX_ENTRIES: usize = 1_000; +pub(super) const DEFAULT_CONVERSATION_REGISTRY_TTL_SECS: u64 = 365 * 24 * 60 * 60; +const REGISTRY_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024; +const REGISTRY_MAX_CONFIGURED_ENTRIES: usize = 10_000; +const REGISTRY_TEMP_MARKER: &str = ".tmp-"; +const FIELD_LIMIT: usize = 256; +const ROUTE_ID_LIMIT: usize = 2_048; +const SERVICE_URL_LIMIT: usize = 4_096; +const FORBIDDEN_DISABLE_THRESHOLD: u8 = 2; + +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct TeamsConversationKey { + pub(super) app_id: String, + pub(super) tenant_id: String, + pub(super) bot_framework_channel_id: String, + pub(super) conversation_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(super) enum TeamsConversationState { + Active, + Disabled, + Revoked, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct TeamsConversationEntry { + schema_version: u32, + #[serde(flatten)] + pub(super) key: TeamsConversationKey, + pub(super) conversation_type: String, + pub(super) service_url: String, + pub(super) team_id: Option, + pub(super) channel_id: Option, + pub(super) last_validated_at: DateTime, + pub(super) updated_at: DateTime, + pub(super) state: TeamsConversationState, + pub(super) reason_code: Option, + pub(super) consecutive_forbidden_writes: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct TeamsConversationRegistryFile { + schema: String, + version: u32, + generation: u64, + entries: Vec, +} + +impl Default for TeamsConversationRegistryFile { + fn default() -> Self { + Self { + schema: REGISTRY_SCHEMA.into(), + version: REGISTRY_VERSION, + generation: 0, + entries: Vec::new(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PersistDurability { + Durable, + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PromotionKind { + Inserted, + Refreshed, + Reactivated, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct RegistryCounts { + pub(crate) active: usize, + pub(crate) disabled: usize, + pub(crate) revoked: usize, +} + +pub(super) struct TeamsConversationRegistry { + path: PathBuf, + state: TeamsConversationRegistryFile, + max_entries: usize, + ttl_secs: i64, +} + +impl TeamsConversationRegistry { + pub(super) fn open(raw_path: &str, max_entries: usize, ttl_secs: u64) -> Result { + if !(1..=REGISTRY_MAX_CONFIGURED_ENTRIES).contains(&max_entries) { + bail!( + "conversation registry max entries must be between 1 and {}", + REGISTRY_MAX_CONFIGURED_ENTRIES + ); + } + let ttl_secs = i64::try_from(ttl_secs) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| anyhow!("conversation registry TTL is out of range"))?; + let path = resolve_registry_path(raw_path)?; + let parent = path + .parent() + .ok_or_else(|| anyhow!("conversation registry path has no parent"))?; + ensure_safe_directory(parent)?; + cleanup_registry_temps(&path)?; + + let mut state = if path.exists() { + load_registry_file(&path, REGISTRY_MAX_CONFIGURED_ENTRIES)? + } else { + TeamsConversationRegistryFile::default() + }; + let now = Utc::now(); + let before = state.entries.len(); + prune_expired(&mut state.entries, now, ttl_secs); + trim_to_capacity(&mut state.entries, max_entries)?; + if state.entries.len() != before { + state.generation = next_generation(state.generation)?; + if persist_registry_file(&path, &state)? == PersistDurability::Unknown { + bail!("conversation registry startup cleanup durability is unknown"); + } + } + + Ok(Self { + path, + state, + max_entries, + ttl_secs, + }) + } + + pub(super) fn promote( + &mut self, + route: &TeamsIngressRoute, + now: DateTime, + ) -> Result { + let entry = entry_from_route(route, now)?; + let key = entry.key.clone(); + let mut candidate = self.state.clone(); + prune_expired(&mut candidate.entries, now, self.ttl_secs); + + let promotion = if let Some(existing) = candidate + .entries + .iter_mut() + .find(|existing| existing.key == key) + { + let kind = if existing.state == TeamsConversationState::Active { + PromotionKind::Refreshed + } else { + PromotionKind::Reactivated + }; + let mut refreshed = entry; + if refreshed.conversation_type == existing.conversation_type { + if refreshed.team_id.is_none() { + refreshed.team_id.clone_from(&existing.team_id); + } + if refreshed.channel_id.is_none() { + refreshed.channel_id.clone_from(&existing.channel_id); + } + } + *existing = refreshed; + kind + } else { + make_capacity(&mut candidate.entries, self.max_entries)?; + candidate.entries.push(entry); + PromotionKind::Inserted + }; + commit_candidate(&self.path, &mut self.state, candidate)?; + Ok(promotion) + } + + pub(super) fn revoke( + &mut self, + key: &TeamsConversationKey, + reason_code: &str, + now: DateTime, + ) -> Result { + Ok(self.revoke_scope(key, None, reason_code, now)? > 0) + } + + pub(super) fn revoke_scope( + &mut self, + key: &TeamsConversationKey, + team_id: Option<&str>, + reason_code: &str, + now: DateTime, + ) -> Result { + validate_key(key)?; + validate_bounded_field("reason code", reason_code, FIELD_LIMIT)?; + validate_optional_id("team id", team_id)?; + let mut candidate = self.state.clone(); + let mut changed = 0; + for entry in &mut candidate.entries { + let identity_matches = entry.key.app_id == key.app_id + && entry.key.tenant_id == key.tenant_id + && entry.key.bot_framework_channel_id == key.bot_framework_channel_id; + let scope_matches = match team_id { + Some(team_id) => entry.team_id.as_deref() == Some(team_id), + None => entry.key == *key, + }; + if !identity_matches + || !scope_matches + || (entry.state == TeamsConversationState::Revoked + && entry.reason_code.as_deref() == Some(reason_code)) + { + continue; + } + entry.state = TeamsConversationState::Revoked; + entry.reason_code = Some(reason_code.into()); + entry.consecutive_forbidden_writes = 0; + entry.updated_at = now; + changed += 1; + } + if changed == 0 { + return Ok(0); + } + commit_candidate(&self.path, &mut self.state, candidate)?; + Ok(changed) + } + + /// PR 12 feeds explicit blocked/not-in-roster outcomes into this transition. + #[allow(dead_code)] + pub(super) fn record_forbidden_write( + &mut self, + key: &TeamsConversationKey, + reason_code: &str, + now: DateTime, + ) -> Result { + validate_bounded_field("reason code", reason_code, FIELD_LIMIT)?; + let mut candidate = self.state.clone(); + let Some(entry) = candidate.entries.iter_mut().find(|entry| &entry.key == key) else { + return Ok(false); + }; + if entry.state == TeamsConversationState::Revoked { + return Ok(false); + } + entry.consecutive_forbidden_writes = entry + .consecutive_forbidden_writes + .saturating_add(1) + .min(FORBIDDEN_DISABLE_THRESHOLD); + if entry.consecutive_forbidden_writes >= FORBIDDEN_DISABLE_THRESHOLD { + entry.state = TeamsConversationState::Disabled; + entry.reason_code = Some(reason_code.into()); + } + entry.updated_at = now; + commit_candidate(&self.path, &mut self.state, candidate)?; + Ok(true) + } + + /// PR 12 clears a prior single forbidden result after a confirmed delivery. + #[allow(dead_code)] + pub(super) fn record_success( + &mut self, + key: &TeamsConversationKey, + now: DateTime, + ) -> Result { + let mut candidate = self.state.clone(); + let Some(entry) = candidate.entries.iter_mut().find(|entry| &entry.key == key) else { + return Ok(false); + }; + if entry.state != TeamsConversationState::Active || entry.consecutive_forbidden_writes == 0 + { + return Ok(false); + } + entry.consecutive_forbidden_writes = 0; + entry.reason_code = None; + entry.updated_at = now; + commit_candidate(&self.path, &mut self.state, candidate)?; + Ok(true) + } + + /// PR 12 consumes only an active, non-expired copy. + #[allow(dead_code)] + pub(super) fn active( + &self, + key: &TeamsConversationKey, + now: DateTime, + ) -> Option { + self.state + .entries + .iter() + .find(|entry| { + &entry.key == key + && entry.state == TeamsConversationState::Active + && !is_expired(entry, now, self.ttl_secs) + }) + .cloned() + } + + pub(super) fn counts(&self) -> RegistryCounts { + let mut counts = RegistryCounts::default(); + for entry in &self.state.entries { + match entry.state { + TeamsConversationState::Active => counts.active += 1, + TeamsConversationState::Disabled => counts.disabled += 1, + TeamsConversationState::Revoked => counts.revoked += 1, + } + } + counts + } + + #[cfg(test)] + fn generation(&self) -> u64 { + self.state.generation + } + + #[cfg(test)] + pub(super) fn insert_route_unchecked_for_test( + &mut self, + route: &TeamsIngressRoute, + now: DateTime, + ) { + let key = key_for_route(route); + self.state.entries.retain(|entry| entry.key != key); + self.state.entries.push(TeamsConversationEntry { + schema_version: REGISTRY_VERSION, + key, + conversation_type: route.conversation_type.clone(), + service_url: route.service_url.as_str().into(), + team_id: route.team_id.clone(), + channel_id: route.channel_id.clone(), + last_validated_at: now, + updated_at: now, + state: TeamsConversationState::Active, + reason_code: None, + consecutive_forbidden_writes: 0, + }); + } + + #[cfg(test)] + pub(super) fn entry_for_test( + &self, + key: &TeamsConversationKey, + ) -> Option { + self.state + .entries + .iter() + .find(|entry| &entry.key == key) + .cloned() + } +} + +pub(super) fn key_for_route(route: &TeamsIngressRoute) -> TeamsConversationKey { + TeamsConversationKey { + app_id: route.key.app_id.clone(), + tenant_id: route.tenant_id.clone(), + bot_framework_channel_id: route.bot_framework_channel_id.clone(), + conversation_id: route.conversation_id.clone(), + } +} + +pub(super) fn key_from_parts( + app_id: &str, + tenant_id: &str, + bot_framework_channel_id: &str, + conversation_id: &str, +) -> Result { + let key = TeamsConversationKey { + app_id: app_id.into(), + tenant_id: tenant_id.into(), + bot_framework_channel_id: bot_framework_channel_id.into(), + conversation_id: conversation_id.into(), + }; + validate_key(&key)?; + Ok(key) +} + +fn entry_from_route( + route: &TeamsIngressRoute, + now: DateTime, +) -> Result { + let key = key_for_route(route); + validate_key(&key)?; + validate_bounded_field("conversation type", &route.conversation_type, FIELD_LIMIT)?; + if !matches!( + route.conversation_type.as_str(), + "personal" | "groupChat" | "channel" + ) { + bail!("conversation registry requires a canonical conversation type"); + } + validate_optional_id("team id", route.team_id.as_deref())?; + validate_optional_id("channel id", route.channel_id.as_deref())?; + if route.conversation_type == "channel" + && (route.team_id.is_none() || route.channel_id.is_none()) + { + bail!("channel conversation registry route is missing Team identity"); + } + let service_url = route.service_url.as_str(); + validate_service_url(service_url)?; + + Ok(TeamsConversationEntry { + schema_version: REGISTRY_VERSION, + key, + conversation_type: route.conversation_type.clone(), + service_url: service_url.into(), + team_id: route.team_id.clone(), + channel_id: route.channel_id.clone(), + last_validated_at: now, + updated_at: now, + state: TeamsConversationState::Active, + reason_code: None, + consecutive_forbidden_writes: 0, + }) +} + +fn validate_registry_file(state: &TeamsConversationRegistryFile, max_entries: usize) -> Result<()> { + if state.schema != REGISTRY_SCHEMA || state.version != REGISTRY_VERSION { + bail!("conversation registry schema version is unsupported"); + } + if state.entries.len() > max_entries { + bail!("conversation registry exceeds configured entry capacity"); + } + let mut keys = HashSet::with_capacity(state.entries.len()); + for entry in &state.entries { + if entry.schema_version != REGISTRY_VERSION { + bail!("conversation registry entry schema version is unsupported"); + } + validate_key(&entry.key)?; + validate_bounded_field("conversation type", &entry.conversation_type, FIELD_LIMIT)?; + if !matches!( + entry.conversation_type.as_str(), + "personal" | "groupChat" | "channel" + ) { + bail!("conversation registry contains an unknown conversation type"); + } + validate_service_url(&entry.service_url)?; + validate_optional_id("team id", entry.team_id.as_deref())?; + validate_optional_id("channel id", entry.channel_id.as_deref())?; + if entry.conversation_type == "channel" + && (entry.team_id.is_none() || entry.channel_id.is_none()) + { + bail!("conversation registry channel entry is missing Team identity"); + } + if let Some(reason) = entry.reason_code.as_deref() { + validate_bounded_field("reason code", reason, FIELD_LIMIT)?; + } + if entry.consecutive_forbidden_writes > FORBIDDEN_DISABLE_THRESHOLD { + bail!("conversation registry contains an invalid failure count"); + } + if !keys.insert(entry.key.clone()) { + bail!("conversation registry contains a duplicate composite key"); + } + } + Ok(()) +} + +fn validate_key(key: &TeamsConversationKey) -> Result<()> { + validate_bounded_field("app id", &key.app_id, FIELD_LIMIT)?; + validate_bounded_field("tenant id", &key.tenant_id, FIELD_LIMIT)?; + validate_bounded_field( + "Bot Framework channel id", + &key.bot_framework_channel_id, + FIELD_LIMIT, + )?; + validate_bounded_field("conversation id", &key.conversation_id, ROUTE_ID_LIMIT) +} + +fn validate_optional_id(label: &str, value: Option<&str>) -> Result<()> { + if let Some(value) = value { + validate_bounded_field(label, value, ROUTE_ID_LIMIT)?; + } + Ok(()) +} + +fn validate_bounded_field(label: &str, value: &str, max_bytes: usize) -> Result<()> { + if value.trim().is_empty() + || value.trim() != value + || value.len() > max_bytes + || value.chars().any(char::is_control) + { + bail!("conversation registry {label} is invalid"); + } + Ok(()) +} + +fn validate_service_url(raw: &str) -> Result<()> { + validate_bounded_field("service URL", raw, SERVICE_URL_LIMIT)?; + let url = reqwest::Url::parse(raw).context("conversation registry service URL is malformed")?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.port().is_some() + || !url + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("smba.trafficmanager.net")) + { + bail!("conversation registry service URL is outside the public Teams boundary"); + } + Ok(()) +} + +fn eviction_candidate(entries: &[TeamsConversationEntry]) -> Result { + entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.state != TeamsConversationState::Revoked) + .min_by_key(|(_, entry)| { + let state_order = match entry.state { + TeamsConversationState::Disabled => 0, + TeamsConversationState::Active => 1, + TeamsConversationState::Revoked => 2, + }; + ( + state_order, + entry.last_validated_at, + entry.key.conversation_id.as_str(), + ) + }) + .map(|(index, _)| index) + .ok_or_else(|| anyhow!("conversation registry is saturated by revoked records")) +} + +fn make_capacity(entries: &mut Vec, max_entries: usize) -> Result<()> { + while entries.len() >= max_entries { + let candidate = eviction_candidate(entries)?; + entries.remove(candidate); + } + Ok(()) +} + +fn trim_to_capacity(entries: &mut Vec, max_entries: usize) -> Result<()> { + while entries.len() > max_entries { + let candidate = eviction_candidate(entries)?; + entries.remove(candidate); + } + Ok(()) +} + +fn prune_expired(entries: &mut Vec, now: DateTime, ttl_secs: i64) { + entries.retain(|entry| { + entry.state == TeamsConversationState::Revoked || !is_expired(entry, now, ttl_secs) + }); +} + +fn is_expired(entry: &TeamsConversationEntry, now: DateTime, ttl_secs: i64) -> bool { + now.signed_duration_since(entry.last_validated_at) + .num_seconds() + > ttl_secs +} + +fn commit_candidate( + path: &Path, + current: &mut TeamsConversationRegistryFile, + mut candidate: TeamsConversationRegistryFile, +) -> Result<()> { + candidate.generation = next_generation(current.generation)?; + candidate.entries.sort_by(|left, right| { + ( + left.key.app_id.as_str(), + left.key.tenant_id.as_str(), + left.key.bot_framework_channel_id.as_str(), + left.key.conversation_id.as_str(), + ) + .cmp(&( + right.key.app_id.as_str(), + right.key.tenant_id.as_str(), + right.key.bot_framework_channel_id.as_str(), + right.key.conversation_id.as_str(), + )) + }); + let durability = persist_registry_file(path, &candidate)?; + *current = candidate; + if durability == PersistDurability::Unknown { + bail!("conversation registry commit durability is unknown"); + } + Ok(()) +} + +fn next_generation(current: u64) -> Result { + current + .checked_add(1) + .ok_or_else(|| anyhow!("conversation registry generation overflow")) +} + +fn resolve_registry_path(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() || raw.as_bytes().contains(&0) { + bail!("conversation registry path is empty or invalid"); + } + let configured = PathBuf::from(raw); + reject_unsafe_components(&configured)?; + let resolved = if configured.is_absolute() { + configured + } else { + let home = std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var_os("USERPROFILE").filter(|value| !value.is_empty())) + .ok_or_else(|| { + anyhow!("HOME or USERPROFILE is required for a relative conversation registry path") + })?; + PathBuf::from(home).join(".openab").join(configured) + }; + if !resolved.is_absolute() { + bail!("conversation registry path did not resolve to an absolute path"); + } + reject_unsafe_components(&resolved)?; + Ok(resolved) +} + +fn reject_unsafe_components(path: &Path) -> Result<()> { + for component in path.components() { + if matches!(component, Component::CurDir | Component::ParentDir) { + bail!("conversation registry path contains traversal components"); + } + if matches!(component, Component::Normal(value) if value.is_empty()) { + bail!("conversation registry path contains an empty component"); + } + } + Ok(()) +} + +fn ensure_safe_directory(path: &Path) -> Result<()> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("conversation registry parent path is not a safe directory"); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + create_registry_directory(¤t)?; + set_directory_permissions(¤t)?; + let metadata = fs::symlink_metadata(¤t) + .context("failed to verify conversation registry directory")?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("conversation registry directory verification failed"); + } + } + Err(error) => { + return Err(error).context("failed to inspect conversation registry directory") + } + } + } + Ok(()) +} + +fn load_registry_file(path: &Path, max_entries: usize) -> Result { + let metadata = fs::symlink_metadata(path).context("failed to inspect conversation registry")?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("conversation registry path is not a regular file"); + } + if metadata.len() > REGISTRY_FILE_MAX_BYTES { + bail!("conversation registry file exceeds the byte limit"); + } + set_file_permissions(path)?; + let file = open_registry_read(path)?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(REGISTRY_FILE_MAX_BYTES + 1) + .read_to_end(&mut bytes) + .context("failed to read conversation registry")?; + if bytes.len() as u64 > REGISTRY_FILE_MAX_BYTES { + bail!("conversation registry file exceeds the byte limit"); + } + let state: TeamsConversationRegistryFile = + serde_json::from_slice(&bytes).context("conversation registry JSON is invalid")?; + validate_registry_file(&state, max_entries)?; + Ok(state) +} + +fn persist_registry_file( + path: &Path, + state: &TeamsConversationRegistryFile, +) -> Result { + validate_registry_file(state, REGISTRY_MAX_CONFIGURED_ENTRIES)?; + let mut bytes = + serde_json::to_vec(state).context("failed to serialize conversation registry")?; + bytes.push(b'\n'); + if bytes.len() as u64 > REGISTRY_FILE_MAX_BYTES { + bail!("conversation registry candidate exceeds the byte limit"); + } + + let parent = path + .parent() + .ok_or_else(|| anyhow!("conversation registry path has no parent"))?; + ensure_safe_directory(parent)?; + reject_unsafe_target(path)?; + let temp = registry_temp_path(path); + let result = (|| -> Result { + let mut file = open_registry_temp(&temp)?; + set_file_permissions(&temp)?; + file.write_all(&bytes) + .context("failed to write conversation registry temporary file")?; + file.flush() + .context("failed to flush conversation registry temporary file")?; + file.sync_all() + .context("failed to sync conversation registry temporary file")?; + drop(file); + reject_unsafe_target(path)?; + atomic_replace(&temp, path).context("failed to replace conversation registry")?; + Ok(match sync_parent(parent) { + Ok(()) => PersistDurability::Durable, + Err(_) => PersistDurability::Unknown, + }) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +fn reject_unsafe_target(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + bail!("conversation registry target is not a safe regular file") + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("failed to inspect conversation registry target"), + } +} + +fn registry_temp_path(path: &Path) -> PathBuf { + let filename = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("registry"); + path.with_file_name(format!( + ".{filename}{REGISTRY_TEMP_MARKER}{}", + uuid::Uuid::new_v4() + )) +} + +fn cleanup_registry_temps(path: &Path) -> Result<()> { + let Some(parent) = path.parent() else { + return Ok(()); + }; + let Some(filename) = path.file_name().and_then(|value| value.to_str()) else { + bail!("conversation registry filename is not valid UTF-8"); + }; + let prefix = format!(".{filename}{REGISTRY_TEMP_MARKER}"); + for entry in + fs::read_dir(parent).context("failed to inspect conversation registry directory")? + { + let entry = entry.context("failed to inspect conversation registry temporary file")?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(suffix) = name.strip_prefix(&prefix) else { + continue; + }; + if uuid::Uuid::parse_str(suffix).is_err() { + continue; + } + let metadata = fs::symlink_metadata(entry.path()) + .context("failed to verify conversation registry temporary file")?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("conversation registry temporary path is unsafe"); + } + fs::remove_file(entry.path()) + .context("failed to remove stale conversation registry temporary file")?; + } + Ok(()) +} + +fn open_registry_read(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + options + .open(path) + .context("failed to open conversation registry") +} + +fn open_registry_temp(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + options + .open(path) + .context("failed to create conversation registry temporary file") +} + +#[cfg(unix)] +fn create_registry_directory(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(path) + .context("failed to create conversation registry directory") +} + +#[cfg(not(unix))] +fn create_registry_directory(path: &Path) -> Result<()> { + fs::create_dir(path).context("failed to create conversation registry directory") +} + +#[cfg(unix)] +fn set_directory_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .context("failed to secure conversation registry directory") +} + +#[cfg(not(unix))] +fn set_directory_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .context("failed to secure conversation registry file") +} + +#[cfg(not(unix))] +fn set_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(not(windows))] +fn atomic_replace(temp: &Path, target: &Path) -> std::io::Result<()> { + fs::rename(temp, target) +} + +#[cfg(windows)] +fn atomic_replace(temp: &Path, target: &Path) -> std::io::Result<()> { + if !target.exists() { + return fs::rename(temp, target); + } + use std::os::windows::ffi::OsStrExt; + use std::ptr; + use windows_sys::Win32::Storage::FileSystem::ReplaceFileW; + + let target_wide: Vec = target.as_os_str().encode_wide().chain(Some(0)).collect(); + let temp_wide: Vec = temp.as_os_str().encode_wide().chain(Some(0)).collect(); + // SAFETY: both UTF-16 buffers are NUL-terminated, remain alive for the + // synchronous call, and ReplaceFileW does not retain supplied pointers. + let result = unsafe { + ReplaceFileW( + target_wide.as_ptr(), + temp_wide.as_ptr(), + ptr::null(), + 0, + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if result == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(unix)] +fn sync_parent(parent: &Path) -> Result<()> { + File::open(parent) + .and_then(|directory| directory.sync_all()) + .context("failed to sync conversation registry directory") +} + +#[cfg(not(unix))] +fn sync_parent(_parent: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapters::teams_ingress::{TeamsIngressRoute, TeamsRouteKey}; + use reqwest::Url; + use std::collections::HashMap; + use std::time::Instant; + + fn test_dir(label: &str) -> PathBuf { + let root = fs::canonicalize(std::env::temp_dir()).expect("temp root"); + let path = root.join(format!( + "openab-teams-registry-{label}-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir(&path).expect("test directory"); + path + } + + fn route(conversation_id: &str) -> TeamsIngressRoute { + let key = TeamsRouteKey::new("app", "tenant", conversation_id, "activity-secret"); + TeamsIngressRoute { + key, + event_id: "event-secret".into(), + tenant_id: "tenant".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: conversation_id.into(), + conversation_type: "personal".into(), + inbound_activity_id: "activity-secret".into(), + reply_chain_root_id: None, + service_url: Url::parse("https://smba.trafficmanager.net/teams") + .expect("test setup or assertion invariant"), + team_id: None, + channel_id: None, + attachment_sources: HashMap::new(), + attachment_materialized_bytes: 0, + created_at: Instant::now(), + } + } + + fn open(path: &Path, max_entries: usize) -> TeamsConversationRegistry { + TeamsConversationRegistry::open( + path.to_str().expect("test setup or assertion invariant"), + max_entries, + 3600, + ) + .expect("test setup or assertion invariant") + } + + #[test] + fn trusted_route_round_trips_without_activity_or_event_identifiers() { + let dir = test_dir("roundtrip"); + let path = dir.join("registry.json"); + let now = Utc::now(); + let mut registry = open(&path, 10); + assert_eq!( + registry.promote(&route("conversation"), now).unwrap(), + PromotionKind::Inserted + ); + assert_eq!(registry.counts().active, 1); + assert_eq!(registry.generation(), 1); + + let raw = fs::read_to_string(&path).expect("test setup or assertion invariant"); + assert!(!raw.contains("activity-secret")); + assert!(!raw.contains("event-secret")); + assert!(raw.contains("smba.trafficmanager.net")); + + let reopened = open(&path, 10); + let key = key_for_route(&route("conversation")); + assert!(reopened.active(&key, now).is_some()); + assert_eq!(reopened.generation(), 1); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn complete_composite_identity_prevents_cross_scope_collision() { + let dir = test_dir("composite-key"); + let path = dir.join("registry.json"); + let now = Utc::now(); + let mut registry = open(&path, 10); + let first = route("shared-conversation"); + let mut second = route("shared-conversation"); + second.key = TeamsRouteKey::new( + "other-app", + "other-tenant", + "shared-conversation", + "other-activity", + ); + second.tenant_id = "other-tenant".into(); + second.bot_framework_channel_id = "other-channel".into(); + registry + .promote(&first, now) + .expect("test setup or assertion invariant"); + registry + .promote(&second, now) + .expect("test setup or assertion invariant"); + assert_eq!(registry.counts().active, 2); + assert!(registry.active(&key_for_route(&first), now).is_some()); + assert!(registry.active(&key_for_route(&second), now).is_some()); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn sparse_refresh_preserves_optional_channel_identity() { + let dir = test_dir("sparse-refresh"); + let path = dir.join("registry.json"); + let now = Utc::now(); + let mut registry = open(&path, 10); + let mut first = route("conversation"); + first.conversation_type = "groupChat".into(); + first.team_id = Some("team".into()); + first.channel_id = Some("channel".into()); + registry + .promote(&first, now) + .expect("test setup or assertion invariant"); + + let mut sparse = first.clone(); + sparse.team_id = None; + sparse.channel_id = None; + assert_eq!( + registry.promote(&sparse, now).unwrap(), + PromotionKind::Refreshed + ); + let entry = registry + .active(&key_for_route(&sparse), now) + .expect("test setup or assertion invariant"); + assert_eq!(entry.team_id.as_deref(), Some("team")); + assert_eq!(entry.channel_id.as_deref(), Some("channel")); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn team_installation_removal_revokes_every_record_in_that_team_only() { + let dir = test_dir("team-revoke"); + let path = dir.join("registry.json"); + let now = Utc::now(); + let mut registry = open(&path, 10); + let mut first = route("team-conversation-1"); + first.conversation_type = "channel".into(); + first.team_id = Some("team-1".into()); + first.channel_id = Some("channel-1".into()); + let mut second = route("team-conversation-2"); + second.conversation_type = "channel".into(); + second.team_id = Some("team-1".into()); + second.channel_id = Some("channel-2".into()); + let mut other = route("team-conversation-3"); + other.conversation_type = "channel".into(); + other.team_id = Some("team-2".into()); + other.channel_id = Some("channel-3".into()); + for route in [&first, &second, &other] { + registry + .promote(route, now) + .expect("test setup or assertion invariant"); + } + assert_eq!( + registry + .revoke_scope( + &key_for_route(&first), + Some("team-1"), + "installation_remove", + now, + ) + .expect("test setup or assertion invariant"), + 2 + ); + assert_eq!(registry.counts().revoked, 2); + assert_eq!(registry.counts().active, 1); + assert!(registry.active(&key_for_route(&other), now).is_some()); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn refresh_reactivates_disabled_and_revoked_records() { + let dir = test_dir("transitions"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 10); + let route = route("conversation"); + let key = key_for_route(&route); + let now = Utc::now(); + registry + .promote(&route, now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&key, "message_writes_blocked", now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&key, "message_writes_blocked", now) + .expect("test setup or assertion invariant"); + assert_eq!(registry.counts().disabled, 1); + assert_eq!( + registry.promote(&route, now).unwrap(), + PromotionKind::Reactivated + ); + assert_eq!(registry.counts().active, 1); + assert!(registry.revoke(&key, "installation_remove", now).unwrap()); + assert_eq!(registry.counts().revoked, 1); + assert_eq!( + registry.promote(&route, now).unwrap(), + PromotionKind::Reactivated + ); + assert_eq!(registry.counts().active, 1); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn successful_write_clears_one_forbidden_result_without_reactivating() { + let dir = test_dir("success-reset"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 10); + let route = route("conversation"); + let key = key_for_route(&route); + let now = Utc::now(); + registry + .promote(&route, now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&key, "message_writes_blocked", now) + .expect("test setup or assertion invariant"); + assert!(registry.record_success(&key, now).unwrap()); + let entry = registry + .active(&key, now) + .expect("test setup or assertion invariant"); + assert_eq!(entry.consecutive_forbidden_writes, 0); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn lowering_capacity_trims_disabled_before_active_on_restart() { + let dir = test_dir("lower-capacity"); + let path = dir.join("registry.json"); + let now = Utc::now(); + let mut registry = open(&path, 2); + let disabled = route("disabled"); + let disabled_key = key_for_route(&disabled); + registry + .promote(&disabled, now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&disabled_key, "blocked", now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&disabled_key, "blocked", now) + .expect("test setup or assertion invariant"); + registry + .promote(&route("active"), now) + .expect("test setup or assertion invariant"); + drop(registry); + + let reopened = open(&path, 1); + assert_eq!(reopened.counts().active, 1); + assert_eq!(reopened.counts().disabled, 0); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn revoked_records_saturate_instead_of_being_evicted() { + let dir = test_dir("revoked-capacity"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 1); + let first = route("first"); + let key = key_for_route(&first); + let now = Utc::now(); + registry + .promote(&first, now) + .expect("test setup or assertion invariant"); + registry + .revoke(&key, "installation_remove", now) + .expect("test setup or assertion invariant"); + assert!(registry.promote(&route("second"), now).is_err()); + assert_eq!(registry.counts().revoked, 1); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn disabled_record_is_evicted_before_active_record() { + let dir = test_dir("capacity-order"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 2); + let now = Utc::now(); + let disabled = route("disabled"); + let disabled_key = key_for_route(&disabled); + registry + .promote(&disabled, now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&disabled_key, "blocked", now) + .expect("test setup or assertion invariant"); + registry + .record_forbidden_write(&disabled_key, "blocked", now) + .expect("test setup or assertion invariant"); + registry + .promote(&route("active"), now) + .expect("test setup or assertion invariant"); + registry + .promote(&route("new"), now) + .expect("test setup or assertion invariant"); + assert_eq!(registry.counts().disabled, 0); + assert_eq!(registry.counts().active, 2); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn expired_active_records_are_pruned_on_restart() { + let dir = test_dir("ttl"); + let path = dir.join("registry.json"); + let old = Utc::now() - chrono::Duration::seconds(5); + let mut registry = TeamsConversationRegistry::open( + path.to_str().expect("test setup or assertion invariant"), + 10, + 1, + ) + .expect("test setup or assertion invariant"); + registry + .promote(&route("conversation"), old) + .expect("test setup or assertion invariant"); + drop(registry); + + let reopened = TeamsConversationRegistry::open( + path.to_str().expect("test setup or assertion invariant"), + 10, + 1, + ) + .expect("test setup or assertion invariant"); + assert_eq!(reopened.counts(), RegistryCounts::default()); + assert_eq!(reopened.generation(), 2); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn failed_candidate_does_not_replace_the_committed_generation() { + let dir = test_dir("failed-candidate"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 10); + registry + .promote(&route("conversation"), Utc::now()) + .expect("test setup or assertion invariant"); + let before = fs::read(&path).expect("test setup or assertion invariant"); + let mut unsafe_route = route("other"); + unsafe_route.service_url = + Url::parse("https://example.com/teams").expect("test setup or assertion invariant"); + assert!(registry.promote(&unsafe_route, Utc::now()).is_err()); + let oversized_conversation = "x".repeat(ROUTE_ID_LIMIT + 1); + assert!(registry + .promote(&route(&oversized_conversation), Utc::now()) + .is_err()); + assert_eq!( + fs::read(&path).expect("test setup or assertion invariant"), + before + ); + assert_eq!(registry.generation(), 1); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn corrupt_or_unknown_file_is_not_replaced() { + let dir = test_dir("corrupt"); + let path = dir.join("registry.json"); + fs::write(&path, b"{not-json").expect("test setup or assertion invariant"); + let before = fs::read(&path).expect("test setup or assertion invariant"); + assert!(TeamsConversationRegistry::open(path.to_str().unwrap(), 10, 3600).is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + + let unknown = br#"{"schema":"openab.teams.conversation_registry.v2","version":2,"generation":0,"entries":[]}"#; + fs::write(&path, unknown).expect("test setup or assertion invariant"); + assert!(TeamsConversationRegistry::open(path.to_str().unwrap(), 10, 3600).is_err()); + assert_eq!(fs::read(&path).unwrap(), unknown); + + let oversized = File::create(&path).expect("test setup or assertion invariant"); + oversized + .set_len(REGISTRY_FILE_MAX_BYTES + 1) + .expect("test setup or assertion invariant"); + drop(oversized); + assert!(TeamsConversationRegistry::open(path.to_str().unwrap(), 10, 3600).is_err()); + assert_eq!( + fs::metadata(&path).unwrap().len(), + REGISTRY_FILE_MAX_BYTES + 1 + ); + assert!(TeamsConversationRegistry::open("../traversal.json", 10, 3600).is_err()); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[cfg(unix)] + #[test] + fn file_permissions_are_tightened_and_symlink_targets_are_rejected() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let dir = test_dir("permissions"); + let path = dir.join("registry.json"); + let mut registry = open(&path, 10); + registry + .promote(&route("conversation"), Utc::now()) + .expect("test setup or assertion invariant"); + assert_eq!( + fs::metadata(&path) + .expect("test setup or assertion invariant") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let nested = dir.join("created").join("nested").join("registry.json"); + let _nested_registry = TeamsConversationRegistry::open( + nested.to_str().expect("test setup or assertion invariant"), + 10, + 3600, + ) + .expect("test setup or assertion invariant"); + for created in [dir.join("created"), dir.join("created").join("nested")] { + assert_eq!( + fs::metadata(created) + .expect("test setup or assertion invariant") + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + + let target = dir.join("target.json"); + fs::write(&target, b"{}").expect("test setup or assertion invariant"); + let link = dir.join("link.json"); + symlink(&target, &link).expect("test setup or assertion invariant"); + assert!(TeamsConversationRegistry::open( + link.to_str().expect("test setup or assertion invariant"), + 10, + 3600 + ) + .is_err()); + + let real_parent = dir.join("real-parent"); + fs::create_dir(&real_parent).expect("test setup or assertion invariant"); + let linked_parent = dir.join("linked-parent"); + symlink(&real_parent, &linked_parent).expect("test setup or assertion invariant"); + let linked_path = linked_parent.join("registry.json"); + assert!(TeamsConversationRegistry::open( + linked_path + .to_str() + .expect("test setup or assertion invariant"), + 10, + 3600 + ) + .is_err()); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } + + #[test] + fn only_valid_registry_temp_names_are_removed() { + let dir = test_dir("temp-cleanup"); + let path = dir.join("registry.json"); + let stale = dir.join(format!( + ".registry.json{REGISTRY_TEMP_MARKER}{}", + uuid::Uuid::new_v4() + )); + let unrelated = dir.join(".registry.json.tmp-keep-me"); + fs::write(&stale, b"stale").expect("test setup or assertion invariant"); + fs::write(&unrelated, b"unrelated").expect("test setup or assertion invariant"); + let _registry = open(&path, 10); + assert!(!stale.exists()); + assert!(unrelated.exists()); + fs::remove_dir_all(dir).expect("test setup or assertion invariant"); + } +} diff --git a/crates/openab-gateway/src/adapters/telegram.rs b/crates/openab-gateway/src/adapters/telegram.rs index f4c381c1a..ebd2dab8d 100644 --- a/crates/openab-gateway/src/adapters/telegram.rs +++ b/crates/openab-gateway/src/adapters/telegram.rs @@ -435,6 +435,10 @@ pub async fn handle_reply( thread_id: tid, message_id: None, error: None, + outcome: Some(crate::schema::WriteOutcomeKind::Delivered), + error_code: None, + retry_after_ms: None, + attachment: None, } } else { let err = body["description"] @@ -449,6 +453,10 @@ pub async fn handle_reply( thread_id: None, message_id: None, error: Some(err), + outcome: Some(crate::schema::WriteOutcomeKind::Rejected), + error_code: Some("platform_rejected".into()), + retry_after_ms: None, + attachment: None, } } } @@ -459,6 +467,10 @@ pub async fn handle_reply( thread_id: None, message_id: None, error: Some(e.to_string()), + outcome: Some(crate::schema::WriteOutcomeKind::Unknown), + error_code: Some("transport_error".into()), + retry_after_ms: None, + attachment: None, }, }; let json = serde_json::to_string(&gw_resp).unwrap(); @@ -791,6 +803,7 @@ async fn download_telegram_media( MediaKind::Audio => crate::media::audio_extension(&mime), }), mime_type: mime, + reference: None, data: String::new(), // No base64 — using file path size: data_bytes.len() as u64, path: Some(path), @@ -919,6 +932,7 @@ async fn download_telegram_document( attachment_type: "text_file".into(), filename: file_name.to_string(), mime_type: mime_type.to_string(), + reference: None, data: String::new(), size: bytes.len() as u64, path: Some(path), diff --git a/crates/openab-gateway/src/adapters/wecom.rs b/crates/openab-gateway/src/adapters/wecom.rs index 97aa84551..3e6139641 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -466,6 +466,10 @@ impl WecomAdapter { thread_id: None, message_id: placeholder_id, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, + attachment: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -502,6 +506,10 @@ impl WecomAdapter { thread_id: None, message_id: None, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, + attachment: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -536,6 +544,10 @@ impl WecomAdapter { thread_id: None, message_id: msg_id, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, + attachment: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -1210,6 +1222,7 @@ async fn download_wecom_image( attachment_type: "image".into(), filename: format!("wecom_{}.{}", chrono::Utc::now().timestamp(), ext), mime_type: mime, + reference: None, data: String::new(), size: compressed.len() as u64, path: Some(path), @@ -1400,6 +1413,7 @@ async fn download_wecom_file( attachment_type: "text_file".into(), filename: filename.to_string(), mime_type: "text/plain".into(), + reference: None, data: String::new(), size, path: Some(path), diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index d2b257c7d..440aa522f 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -4,6 +4,7 @@ pub mod schema; pub mod store; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; use tokio::sync::{broadcast, Mutex, Semaphore}; @@ -65,7 +66,6 @@ pub struct AppState { /// Webhook mount path for Teams (env: `TEAMS_WEBHOOK_PATH`; config-first /// via `apply_teams_config`, default `/webhook/teams`). pub teams_webhook_path: String, - pub teams_service_urls: Mutex>, #[cfg(feature = "feishu")] pub feishu: Option, #[cfg(feature = "googlechat")] @@ -85,6 +85,9 @@ pub struct AppState { pub lineworks: Option>, pub ws_token: Option, pub event_tx: broadcast::Sender, + /// Number of active OAB WebSocket consumers. M0 supports one; additional + /// consumers are admitted for compatibility but marked unsupported. + pub active_oab_consumers: Arc, pub reply_token_cache: ReplyTokenCache, pub line_webhook_semaphore: Arc, /// Bounds post-ack LINE WORKS webhook processing (mention gate + attachment download). @@ -97,7 +100,6 @@ pub struct AppState { pub client: reqwest::Client, } - impl AppState { /// Create a minimal AppState for testing. Only requires an `event_tx` sender; /// all adapter fields default to `None`/empty. This decouples adapter tests @@ -121,7 +123,6 @@ impl AppState { #[cfg(feature = "teams")] teams: None, teams_webhook_path: "/webhook/teams".into(), - teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu: None, #[cfg(feature = "googlechat")] @@ -139,11 +140,14 @@ impl AppState { lineworks: None, ws_token: None, event_tx, + active_oab_consumers: Arc::new(AtomicUsize::new(0)), reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), - lineworks_webhook_semaphore: Arc::new(Semaphore::new(LINEWORKS_WEBHOOK_CONCURRENCY_MAX)), - lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), - trust_probe: None, + lineworks_webhook_semaphore: Arc::new(Semaphore::new( + LINEWORKS_WEBHOOK_CONCURRENCY_MAX, + )), + lineworks_ingress_queue: Arc::new(Semaphore::new(LINEWORKS_INGRESS_QUEUE_MAX)), + trust_probe: None, client: reqwest::Client::new(), } } @@ -243,7 +247,6 @@ impl AppState { #[cfg(feature = "teams")] teams, teams_webhook_path, - teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu, #[cfg(feature = "googlechat")] @@ -261,6 +264,7 @@ impl AppState { lineworks, ws_token, event_tx, + active_oab_consumers: Arc::new(AtomicUsize::new(0)), reply_token_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), lineworks_webhook_semaphore: Arc::new(Semaphore::new(LINEWORKS_WEBHOOK_CONCURRENCY_MAX)), @@ -270,6 +274,143 @@ impl AppState { } } + /// Capabilities advertised to a new Core during the optional WebSocket + /// hello exchange. Only configured adapters are included, and operation ACK + /// flags are conservative: a platform is advertised only when its current + /// handler emits a GatewayResponse for that operation. + pub fn gateway_capabilities(&self) -> HashMap { + #[cfg(feature = "teams")] + use schema::TEAMS_TEXT_UTF16_BUDGET_BYTES; + use schema::{AdapterCapabilities, MessageLimit, StatusBackend, StreamingMode}; + + let mut capabilities = HashMap::new(); + let mut insert = |platform: &str, value: AdapterCapabilities| { + capabilities.insert(platform.to_string(), value); + }; + let characters = |max| MessageLimit::Characters { max }; + + if self.telegram_bot_token.is_some() { + insert( + "telegram", + AdapterCapabilities { + can_edit: self.telegram_rich_messages, + streaming_mode: if self.telegram_rich_messages { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }, + show_streaming_placeholder: !self.telegram_rich_messages, + message_limit: characters(4096), + supports_reactions: true, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }, + ); + } + if self.line_access_token.is_some() { + insert( + "line", + AdapterCapabilities { + message_limit: characters(4096), + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "teams")] + if let Some(teams) = &self.teams { + insert( + "teams", + AdapterCapabilities { + send_ack: true, + edit_ack: true, + delete_ack: true, + supports_target_message_id: true, + can_edit: true, + can_delete: true, + show_streaming_placeholder: true, + message_limit: MessageLimit::Utf16Bytes { + max: TEAMS_TEXT_UTF16_BUDGET_BYTES, + }, + supports_reactions: teams.reactions_enabled(), + supports_attachment_materialization: teams.inbound_attachments_enabled(), + supports_conversation_registry: teams.conversation_registry_available(), + supports_persistent_conversation_send: teams.conversation_registry_available(), + status_backend: if teams.reactions_enabled() { + StatusBackend::Reactions + } else { + StatusBackend::None + }, + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "feishu")] + if self.feishu.is_some() { + insert( + "feishu", + AdapterCapabilities { + send_ack: true, + edit_ack: true, + delete_ack: true, + can_edit: true, + can_delete: true, + streaming_mode: StreamingMode::Edit, + message_limit: characters(4096), + supports_reactions: true, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "googlechat")] + if self.google_chat.is_some() { + insert( + "googlechat", + AdapterCapabilities { + send_ack: true, + can_edit: true, + streaming_mode: StreamingMode::Edit, + message_limit: characters(4096), + supports_reactions: true, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "wecom")] + if self.wecom.is_some() { + insert( + "wecom", + AdapterCapabilities { + message_limit: characters(2048), + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "lineworks")] + if self.lineworks.is_some() { + insert( + "lineworks", + AdapterCapabilities { + message_limit: characters(2000), + ..AdapterCapabilities::default() + }, + ); + } + #[cfg(feature = "acp")] + if self.acp.is_some() { + insert( + "acp", + AdapterCapabilities { + message_limit: MessageLimit::Unlimited, + show_streaming_placeholder: false, + ..AdapterCapabilities::default() + }, + ); + } + capabilities + } + /// Phase 1 L1 audit (#1356): warn loudly for each **active** webhook /// platform whose transport authentication (L1) secret is unconfigured. /// @@ -468,12 +609,29 @@ impl AppState { pub fn apply_teams_config(&mut self, cfg: GatewayTeamsConfig) { self.teams_webhook_path = cfg.webhook_path; let tenants = cfg.allowed_tenants.join(","); + let dedupe_ttl_secs = cfg.dedupe_ttl_secs.to_string(); + let route_ttl_secs = cfg.route_ttl_secs.to_string(); + let max_route_entries = cfg.max_route_entries.to_string(); + let reactions_enabled = cfg.reactions_enabled.to_string(); + let inbound_attachments = cfg.inbound_attachments.to_string(); + let conversation_registry_max_entries = cfg.conversation_registry_max_entries.to_string(); + let conversation_registry_ttl_secs = cfg.conversation_registry_ttl_secs.to_string(); self.teams = adapters::teams::TeamsConfig::from_reader(|k| match k { "TEAMS_APP_ID" => cfg.app_id.clone(), "TEAMS_APP_SECRET" => cfg.app_secret.clone(), "TEAMS_OAUTH_ENDPOINT" => Some(cfg.oauth_endpoint.clone()), "TEAMS_OPENID_METADATA" => Some(cfg.openid_metadata.clone()), "TEAMS_ALLOWED_TENANTS" => Some(tenants.clone()), + "TEAMS_DEDUPE_TTL_SECS" => Some(dedupe_ttl_secs.clone()), + "TEAMS_ROUTE_TTL_SECS" => Some(route_ttl_secs.clone()), + "TEAMS_MAX_ROUTE_ENTRIES" => Some(max_route_entries.clone()), + "TEAMS_REACTIONS_ENABLED" => Some(reactions_enabled.clone()), + "TEAMS_INBOUND_ATTACHMENTS" => Some(inbound_attachments.clone()), + "TEAMS_CONVERSATION_REGISTRY_PATH" => cfg.conversation_registry_path.clone(), + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES" => { + Some(conversation_registry_max_entries.clone()) + } + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS" => Some(conversation_registry_ttl_secs.clone()), _ => None, }) .map(adapters::teams::TeamsAdapter::new); @@ -567,6 +725,44 @@ pub struct GatewayTeamsConfig { pub oauth_endpoint: String, pub openid_metadata: String, pub webhook_path: String, + pub dedupe_ttl_secs: u64, + pub route_ttl_secs: u64, + pub max_route_entries: usize, + pub reactions_enabled: bool, + pub inbound_attachments: bool, + pub conversation_registry_path: Option, + pub conversation_registry_max_entries: usize, + pub conversation_registry_ttl_secs: u64, +} + +/// Start the shared Teams state sweeper for Standalone or Unified mode. +#[cfg(feature = "teams")] +pub fn spawn_teams_ingress_cleanup(state: Arc) { + const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); + + tokio::spawn(async move { + loop { + tokio::time::sleep(SWEEP_INTERVAL).await; + let Some(teams) = state.teams.as_ref() else { + continue; + }; + + let stats = teams.cleanup_ingress().await; + if stats.routes_removed > 0 + || stats.dedupe_entries_removed > 0 + || stats.stale_publications_removed > 0 + || stats.owned_activities_removed > 0 + { + tracing::info!( + routes_removed = stats.routes_removed, + dedupe_entries_removed = stats.dedupe_entries_removed, + stale_publications_removed = stats.stale_publications_removed, + owned_activities_removed = stats.owned_activities_removed, + "teams ingress state cleanup" + ); + } + } + }); } /// Parameter object for passing resolved Feishu config across the crate @@ -825,7 +1021,6 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { #[cfg(feature = "teams")] teams, teams_webhook_path, - teams_service_urls: Mutex::new(HashMap::new()), #[cfg(feature = "feishu")] feishu, #[cfg(feature = "googlechat")] @@ -843,6 +1038,7 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { lineworks, ws_token, event_tx, + active_oab_consumers: Arc::new(AtomicUsize::new(0)), reply_token_cache, line_webhook_semaphore: Arc::new(Semaphore::new(LINE_WEBHOOK_CONCURRENCY_MAX)), lineworks_webhook_semaphore: Arc::new(Semaphore::new(LINEWORKS_WEBHOOK_CONCURRENCY_MAX)), @@ -885,22 +1081,9 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { }); } - // Background: cleanup stale Teams service_url entries (TTL: 4h) - { - let state_for_cleanup = state.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_secs(300)).await; - let mut urls = state_for_cleanup.teams_service_urls.lock().await; - let before = urls.len(); - urls.retain(|_, (_, t)| t.elapsed().as_secs() < 4 * 3600); - let after = urls.len(); - if before != after { - info!(removed = before - after, remaining = after, "teams service_url cache cleanup"); - } - } - }); - } + // Background: sweep bounded Teams route and dedupe state. + #[cfg(feature = "teams")] + spawn_teams_ingress_cleanup(state.clone()); let app = app.with_state(state.clone()); @@ -936,6 +1119,8 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { // --- Internal handler functions used by serve() --- +const GATEWAY_WS_MESSAGE_LIMIT: usize = 8 * 1024 * 1024; + async fn ws_handler( axum::extract::State(state): axum::extract::State>, query: axum::extract::Query>, @@ -951,27 +1136,126 @@ async fn ws_handler( return axum::http::StatusCode::UNAUTHORIZED.into_response(); } } - ws.on_upgrade(move |socket| handle_oab_connection(state, socket)) + ws.max_message_size(GATEWAY_WS_MESSAGE_LIMIT) + .max_frame_size(GATEWAY_WS_MESSAGE_LIMIT) + .on_upgrade(move |socket| handle_oab_connection(state, socket)) +} + +struct ActiveConsumerGuard { + counter: Arc, +} + +impl ActiveConsumerGuard { + fn enter(counter: Arc) -> (Self, usize) { + let active = counter.fetch_add(1, Ordering::AcqRel) + 1; + (Self { counter }, active) + } +} + +impl Drop for ActiveConsumerGuard { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::AcqRel); + } +} + +fn build_gateway_hello( + state: &AppState, + client_hello: &schema::GatewayClientHello, +) -> schema::GatewayHello { + let mut capabilities = state.gateway_capabilities(); + if !client_hello.requested_platforms.is_empty() { + capabilities.retain(|platform, _| { + client_hello + .requested_platforms + .iter() + .any(|requested| requested == platform) + }); + } + let active_consumers = state.active_oab_consumers.load(Ordering::Acquire); + if let Some(teams) = capabilities.get_mut("teams") { + teams.supports_persistent_conversation_send &= active_consumers == 1 && teams.send_ack; + } + schema::GatewayHello { + schema: schema::GATEWAY_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + capabilities, + topology: schema::GatewayTopology { + active_consumers, + supported: active_consumers == 1, + delivery_mode: "best_effort_broadcast".into(), + }, + } +} + +#[cfg(feature = "teams")] +fn publish_teams_write_outcome( + reply: &schema::GatewayReply, + outcome: schema::WriteOutcome, + event_tx: &broadcast::Sender, +) { + let Some(request_id) = reply.request_id.as_ref() else { + // A legacy peer may omit request IDs entirely. Preserve that + // fire-and-forget wire behavior instead of sending an unsolicited + // response; legacy requests that do carry an ID receive compatible + // legacy fields plus additive outcome metadata. + return; + }; + let response = schema::GatewayResponse::from_write_outcome(request_id, outcome); + match serde_json::to_string(&response) { + Ok(json) => { + if event_tx.send(json).is_err() { + tracing::warn!(request_id, "teams: no consumer received write outcome"); + } + } + Err(error) => { + tracing::error!(request_id, error = %error, "teams: failed to serialize write outcome"); + } + } } async fn handle_oab_connection(state: Arc, socket: axum::extract::ws::WebSocket) { use axum::extract::ws::Message; use futures_util::{SinkExt, StreamExt}; - use tracing::{info, warn}; + use tracing::{error, info, warn}; + + let (_consumer_guard, active_consumers) = + ActiveConsumerGuard::enter(state.active_oab_consumers.clone()); + if active_consumers > 1 { + error!( + active_consumers, + topology_supported = false, + "multiple active OAB consumers detected; M0 broadcast topology is unsupported and may duplicate events" + ); + } let (mut ws_tx, mut ws_rx) = socket.split(); let mut event_rx = state.event_tx.subscribe(); + let (control_tx, mut control_rx) = tokio::sync::mpsc::channel::(4); - info!("OAB client connected via WebSocket"); + info!(active_consumers, "OAB client connected via WebSocket"); - let send_task = tokio::spawn(async move { + let mut send_task = tokio::spawn(async move { loop { tokio::select! { - Ok(event_json) = event_rx.recv() => { - if ws_tx.send(Message::Text(event_json.into())).await.is_err() { + biased; + Some(control_json) = control_rx.recv() => { + if ws_tx.send(Message::Text(control_json.into())).await.is_err() { break; } } + event = event_rx.recv() => { + match event { + Ok(event_json) => { + if ws_tx.send(Message::Text(event_json.into())).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + warn!(skipped, "OAB consumer lagged gateway broadcast; events were lost"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } } } }); @@ -979,18 +1263,103 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: let state_for_recv = state.clone(); let reaction_state: Arc>>> = Arc::new(Mutex::new(HashMap::new())); - let recv_task = tokio::spawn(async move { + let mut recv_task = tokio::spawn(async move { let client = reqwest::Client::new(); + #[cfg(feature = "teams")] + let mut attachment_materialization_negotiated = false; + #[cfg(feature = "teams")] + let mut conversation_registry_negotiated = false; + #[cfg(feature = "teams")] + let mut persistent_conversation_send_negotiated = false; while let Some(Ok(msg)) = ws_rx.next().await { if let Message::Text(text) = msg { + if let Ok(envelope) = serde_json::from_str::(&text) { + if envelope.schema == schema::CLIENT_HELLO_SCHEMA { + match serde_json::from_str::(&text) { + Ok(client_hello) => { + if client_hello.protocol_version != schema::GATEWAY_PROTOCOL_VERSION { + warn!( + client_version = client_hello.protocol_version, + gateway_version = schema::GATEWAY_PROTOCOL_VERSION, + "gateway protocol version differs; responding with supported version" + ); + } + let hello = build_gateway_hello(&state_for_recv, &client_hello); + #[cfg(feature = "teams")] + { + attachment_materialization_negotiated = client_hello + .protocol_version + == schema::GATEWAY_PROTOCOL_VERSION + && hello.capabilities.get("teams").is_some_and( + |capability| { + capability.supports_attachment_materialization + }, + ); + conversation_registry_negotiated = client_hello + .protocol_version + == schema::GATEWAY_PROTOCOL_VERSION + && hello.topology.supported + && hello.capabilities.get("teams").is_some_and( + |capability| capability.supports_conversation_registry, + ); + persistent_conversation_send_negotiated = client_hello + .protocol_version + == schema::GATEWAY_PROTOCOL_VERSION + && hello.topology.supported + && hello.capabilities.get("teams").is_some_and( + |capability| { + capability.send_ack + && capability + .supports_persistent_conversation_send + }, + ); + } + if let Ok(json) = serde_json::to_string(&hello) { + if control_tx.send(json).await.is_err() { + break; + } + } + continue; + } + Err(error) => { + warn!(error = %error, "invalid gateway client hello"); + continue; + } + } + } + } + match serde_json::from_str::(&text) { Ok(reply) => { + if reply.persistent_conversation.is_some() { + info!( + platform = %reply.platform, + operation = ?reply.command.as_deref().unwrap_or("send"), + "OAB → gateway persistent reply" + ); + } else { info!( platform = %reply.platform, channel = %redact_channel(&reply.channel.id), command = ?reply.command.as_deref(), "OAB → gateway reply" ); + } + #[cfg(feature = "teams")] + if reply.persistent_conversation.is_some() && reply.platform != "teams" { + publish_teams_write_outcome( + &reply, + schema::WriteOutcome::Rejected { + code: "persistent_platform_mismatch".into(), + message: + "persistent conversation send is only available for Teams" + .into(), + retry_after_ms: None, + }, + &state_for_recv.event_tx, + ); + continue; + } match reply.platform.as_str() { #[cfg(feature = "telegram")] "telegram" => { @@ -1025,16 +1394,220 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: } #[cfg(feature = "teams")] "teams" => { - if let Some(ref teams) = state_for_recv.teams { - adapters::teams::handle_reply( + if reply.persistent_conversation.is_some() { + let has_request_id = reply + .request_id + .as_deref() + .is_some_and(|request_id| !request_id.trim().is_empty()); + let topology_supported = + state_for_recv.active_oab_consumers.load(Ordering::Acquire) + == 1; + let command_supported = !matches!( + reply.command.as_deref(), + Some("register_conversation" | "materialize_attachment") + ); + if !persistent_conversation_send_negotiated + || !topology_supported + || !has_request_id + || !command_supported + { + publish_teams_write_outcome( + &reply, + schema::WriteOutcome::Rejected { + code: if !topology_supported { + "unsupported_topology" + } else if !has_request_id { + "request_id_missing" + } else if !command_supported { + "persistent_command_rejected" + } else { + "capability_not_negotiated" + } + .into(), + message: "Teams persistent conversation send is unavailable".into(), + retry_after_ms: None, + }, + &state_for_recv.event_tx, + ); + continue; + } + } + if reply.command.as_deref() == Some("register_conversation") { + let Some(_request_id) = reply + .request_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: conversation registration has no request id"); + continue; + }; + let outcome = match ( + conversation_registry_negotiated, + state_for_recv + .active_oab_consumers + .load(Ordering::Acquire) + == 1, + state_for_recv.teams.as_ref(), + ) { + (false, _, _) => schema::WriteOutcome::Rejected { + code: "capability_not_negotiated".into(), + message: "conversation registry capability was not negotiated".into(), + retry_after_ms: None, + }, + (true, false, _) => schema::WriteOutcome::Rejected { + code: "unsupported_topology".into(), + message: "conversation registration requires one active Core consumer".into(), + retry_after_ms: None, + }, + (true, true, Some(teams)) => { + adapters::teams::handle_reply(&reply, teams).await + } + (true, true, None) => schema::WriteOutcome::Rejected { + code: "adapter_not_configured".into(), + message: "Teams adapter is not configured".into(), + retry_after_ms: None, + }, + }; + publish_teams_write_outcome( &reply, - teams, - &state_for_recv.teams_service_urls, - ) - .await; + outcome, + &state_for_recv.event_tx, + ); + continue; + } + if reply.command.as_deref() == Some("materialize_attachment") { + let Some(request_id) = reply + .request_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + warn!("teams: materialization command has no request id"); + continue; + }; + let response = match ( + attachment_materialization_negotiated, + state_for_recv + .active_oab_consumers + .load(Ordering::Acquire) + == 1, + state_for_recv.teams.as_ref(), + reply.attachment_ref.as_deref().filter(|value| { + !value.trim().is_empty() + }), + ) { + (false, _, _, _) => { + schema::GatewayResponse::from_command_error( + request_id, + "capability_not_negotiated", + "attachment materialization capability was not negotiated", + ) + } + (true, false, _, _) => { + schema::GatewayResponse::from_command_error( + request_id, + "unsupported_topology", + "attachment materialization requires one active Core consumer", + ) + } + (true, true, Some(teams), Some(reference)) => { + match teams + .materialize_attachment( + &reply.reply_to, + &reply.channel.id, + reference, + ) + .await + { + Ok(attachment) => { + schema::GatewayResponse::from_attachment( + request_id, + attachment, + ) + } + Err(error) => { + schema::GatewayResponse::from_command_error( + request_id, + error.code(), + error.message(), + ) + } + } + } + (true, true, None, _) => { + schema::GatewayResponse::from_command_error( + request_id, + "adapter_not_configured", + "Teams adapter is not configured", + ) + } + (true, true, _, None) => { + schema::GatewayResponse::from_command_error( + request_id, + "attachment_reference_missing", + "attachment materialization reference is missing", + ) + } + }; + match serde_json::to_string(&response) { + Ok(json) => { + if control_tx.send(json).await.is_err() { + break; + } + } + Err(error) => { + error!(error = %error, "teams: failed to serialize materialization response"); + } + } + continue; + } + let outcome = if let Some(ref teams) = state_for_recv.teams { + adapters::teams::handle_reply(&reply, teams).await } else { warn!("reply for teams but adapter not configured"); + schema::WriteOutcome::Rejected { + code: "adapter_not_configured".into(), + message: "Teams adapter is not configured".into(), + retry_after_ms: None, + } + }; + match &outcome { + schema::WriteOutcome::Rejected { code, message, .. } => { + if reply.persistent_conversation.is_some() { + error!( + error_code = %code, + command = ?reply.command.as_deref(), + "teams persistent reply rejected" + ); + } else { + error!( + error_code = %code, + error = %message, + command = ?reply.command.as_deref(), + "teams reply rejected" + ); + } + } + schema::WriteOutcome::Unknown { code, message } => { + if reply.persistent_conversation.is_some() { + warn!( + error_code = %code, + "teams persistent reply delivery is unknown; not retrying" + ); + } else { + warn!( + error_code = %code, + error = %message, + "teams reply delivery is unknown; not retrying" + ); + } + } + schema::WriteOutcome::Delivered { .. } => {} } + publish_teams_write_outcome( + &reply, + outcome, + &state_for_recv.event_tx, + ); } #[cfg(feature = "feishu")] "feishu" => { @@ -1101,8 +1674,8 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: }); tokio::select! { - _ = send_task => {}, - _ = recv_task => {}, + _ = &mut send_task => recv_task.abort(), + _ = &mut recv_task => send_task.abort(), } info!("OAB client disconnected"); } @@ -1191,7 +1764,11 @@ mod l1_audit_tests { pairs: pairs.clone(), }); assert!(s.feishu.is_some()); - let cfg = &s.feishu.as_ref().unwrap().config; + let cfg = &s + .feishu + .as_ref() + .expect("complete Feishu credentials should build an adapter") + .config; assert_eq!(cfg.app_id, "cli_x"); assert!(matches!( cfg.connection_mode, @@ -1201,6 +1778,14 @@ mod l1_audit_tests { // Config-supplied encrypt_key satisfies the L1 startup check // when the webhook route is exposed. assert!(s.unenforceable_l1(true).is_empty()); + let capabilities = s.gateway_capabilities(); + let feishu = capabilities + .get("feishu") + .expect("configured Feishu adapter should advertise capabilities"); + assert!(feishu.send_ack); + assert!(feishu.edit_ack); + assert!(feishu.delete_ack); + assert_eq!(feishu.streaming_mode, super::schema::StreamingMode::Edit); // Missing secret → adapter disabled. pairs.remove("FEISHU_APP_SECRET"); @@ -1221,9 +1806,35 @@ mod l1_audit_tests { oauth_endpoint: "https://x/token".into(), openid_metadata: "https://x/oidc".into(), webhook_path: "/hook/teams".into(), + dedupe_ttl_secs: 600, + route_ttl_secs: 3600, + max_route_entries: 10_000, + reactions_enabled: true, + inbound_attachments: true, + conversation_registry_path: None, + conversation_registry_max_entries: 1_000, + conversation_registry_ttl_secs: 365 * 24 * 60 * 60, }); assert!(s.teams.is_some()); assert_eq!(s.teams_webhook_path, "/hook/teams"); + let capabilities = s.gateway_capabilities(); + let teams = capabilities + .get("teams") + .expect("configured Teams adapter should advertise capabilities"); + assert!(teams.send_ack); + assert!(teams.edit_ack); + assert!(teams.delete_ack); + assert!(teams.supports_target_message_id); + assert!(teams.supports_attachment_materialization); + assert!(!teams.supports_conversation_registry); + assert!(teams.can_edit); + assert!(teams.can_delete); + assert_eq!(teams.streaming_mode, super::schema::StreamingMode::Disabled); + assert!(teams.show_streaming_placeholder); + assert_eq!( + teams.status_backend, + super::schema::StatusBackend::Reactions + ); // Missing secret → adapter disabled (same as env-only semantics). s.apply_teams_config(GatewayTeamsConfig { @@ -1233,6 +1844,14 @@ mod l1_audit_tests { oauth_endpoint: "https://x/token".into(), openid_metadata: "https://x/oidc".into(), webhook_path: "/hook/teams".into(), + dedupe_ttl_secs: 600, + route_ttl_secs: 3600, + max_route_entries: 10_000, + reactions_enabled: false, + inbound_attachments: false, + conversation_registry_path: None, + conversation_registry_max_entries: 1_000, + conversation_registry_ttl_secs: 365 * 24 * 60 * 60, }); assert!(s.teams.is_none()); } @@ -1300,6 +1919,874 @@ mod l1_audit_tests { } } +#[cfg(test)] +mod gateway_protocol_tests { + use super::*; + use anyhow::Context as _; + use axum::{routing::get, Router}; + use futures_util::{SinkExt, StreamExt}; + use tokio::time::{sleep, timeout, Duration}; + use tokio_tungstenite::tungstenite::Message; + #[cfg(feature = "teams")] + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + type TestSocket = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + async fn start_server( + state: AppState, + ) -> anyhow::Result<( + std::net::SocketAddr, + Arc, + tokio::task::JoinHandle<()>, + )> { + let state = Arc::new(state); + let app = Router::new() + .route("/ws", get(ws_handler)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("loopback test server should run"); + }); + Ok((addr, state, task)) + } + + async fn wait_for_consumers(state: &AppState, expected: usize) -> anyhow::Result<()> { + timeout(Duration::from_secs(1), async { + loop { + if state.active_oab_consumers.load(Ordering::Acquire) == expected { + return; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) + } + + async fn next_text(socket: &mut TestSocket) -> anyhow::Result { + let message = timeout(Duration::from_secs(1), socket.next()) + .await? + .context("test WebSocket closed before a frame arrived")??; + Ok(message.into_text()?) + } + + #[cfg(feature = "teams")] + fn teams_test_config(server: &MockServer) -> adapters::teams::TeamsConfig { + adapters::teams::TeamsConfig { + app_id: "test-app".into(), + app_secret: "test-secret".into(), + oauth_endpoint: format!("{}/token", server.uri()), + openid_metadata: format!("{}/openid", server.uri()), + allowed_tenants: Vec::new(), + dedupe_ttl_secs: 600, + route_ttl_secs: 3600, + max_route_entries: 10_000, + reactions_enabled: false, + inbound_attachments: false, + conversation_registry_path: None, + conversation_registry_max_entries: 1_000, + conversation_registry_ttl_secs: 365 * 24 * 60 * 60, + } + } + + #[tokio::test] + async fn hello_advertises_requested_capabilities_and_topology() -> anyhow::Result<()> { + let (event_tx, _event_rx) = broadcast::channel(8); + let mut app_state = AppState::test_default(event_tx); + app_state.telegram_bot_token = Some("bot-token".into()); + app_state.telegram_rich_messages = true; + app_state.line_access_token = Some("line-token".into()); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + + let (mut first, _) = tokio_tungstenite::connect_async(&url).await?; + let client_hello = schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["telegram".into()], + }; + first + .send(Message::Text(serde_json::to_string(&client_hello)?)) + .await?; + let text = next_text(&mut first).await?; + let hello: schema::GatewayHello = serde_json::from_str(&text)?; + assert_eq!(hello.protocol_version, schema::GATEWAY_PROTOCOL_VERSION); + assert_eq!(hello.capabilities.len(), 1); + let telegram = hello + .capabilities + .get("telegram") + .context("telegram capability should be advertised")?; + assert_eq!(telegram.streaming_mode, schema::StreamingMode::Edit); + assert!(telegram.supports_reactions); + assert!(!telegram.show_streaming_placeholder); + assert!(hello.topology.supported); + assert_eq!(hello.topology.active_consumers, 1); + + let (mut second, _) = tokio_tungstenite::connect_async(&url).await?; + second + .send(Message::Text(serde_json::to_string(&client_hello)?)) + .await?; + let text = next_text(&mut second).await?; + let hello: schema::GatewayHello = serde_json::from_str(&text)?; + assert!(!hello.topology.supported); + assert_eq!(hello.topology.active_consumers, 2); + assert_eq!(hello.topology.delivery_mode, "best_effort_broadcast"); + + second.close(None).await?; + wait_for_consumers(&state, 1).await?; + first.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + Ok(()) + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn negotiated_teams_writes_return_operation_specific_acks_over_websocket( + ) -> anyhow::Result<()> { + let connector = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "teams-activity-1"})), + ) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _edit = Mock::given(method("PUT")) + .and(path( + "/v3/conversations/conversation-1/activities/teams-activity-1", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _delete = Mock::given(method("DELETE")) + .and(path( + "/v3/conversations/conversation-1/activities/teams-activity-1", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount_as_scoped(&connector) + .await; + let teams = adapters::teams::TeamsAdapter::new_for_test(teams_test_config(&connector)); + teams + .accept_route_for_test( + &connector.uri(), + "event-1", + "tenant-1", + "conversation-1", + "inbound-1", + None, + ) + .await?; + + let (event_tx, _event_rx) = broadcast::channel(8); + let mut app_state = AppState::test_default(event_tx); + app_state.teams = Some(teams); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["teams".into()], + }, + )?)) + .await?; + let hello: schema::GatewayHello = serde_json::from_str(&next_text(&mut socket).await?)?; + let capabilities = hello + .capabilities + .get("teams") + .context("Teams capability should be advertised")?; + assert!(capabilities.send_ack); + assert!(capabilities.edit_ack); + assert!(capabilities.delete_ack); + assert!(capabilities.supports_target_message_id); + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-1".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: "hello".into(), + attachments: Vec::new(), + }, + command: None, + request_id: Some("request-1".into()), + quote_message_id: None, + target_message_id: None, + persistent_conversation: None, + }, + )?)) + .await?; + let response: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!(response.request_id, "request-1"); + assert_eq!( + response.write_outcome(), + schema::WriteOutcome::Delivered { + message_id: Some("teams-activity-1".into()) + } + ); + + for (request_id, command, text) in [ + ("request-2", "edit_message", "updated"), + ("request-3", "delete_message", ""), + ] { + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-1".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: text.into(), + attachments: Vec::new(), + }, + command: Some(command.into()), + request_id: Some(request_id.into()), + quote_message_id: None, + target_message_id: Some("teams-activity-1".into()), + persistent_conversation: None, + }, + )?)) + .await?; + let response: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!(response.request_id, request_id); + assert_eq!( + response.write_outcome(), + schema::WriteOutcome::Delivered { message_id: None } + ); + } + + socket.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + Ok(()) + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn legacy_teams_send_emits_no_unsolicited_ack() -> anyhow::Result<()> { + let connector = MockServer::start().await; + let _token = Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "test-token", + "expires_in": 3600 + }))) + .expect(1) + .mount_as_scoped(&connector) + .await; + let _activity = Mock::given(method("POST")) + .and(path("/v3/conversations/conversation-1/activities")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"id": "legacy-activity"})), + ) + .expect(1) + .mount_as_scoped(&connector) + .await; + let teams = adapters::teams::TeamsAdapter::new_for_test(teams_test_config(&connector)); + teams + .accept_route_for_test( + &connector.uri(), + "legacy-event", + "tenant-1", + "conversation-1", + "inbound-1", + None, + ) + .await?; + + let (event_tx, _event_rx) = broadcast::channel(8); + let mut app_state = AppState::test_default(event_tx); + app_state.teams = Some(teams); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "legacy-event".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: "legacy".into(), + attachments: Vec::new(), + }, + command: None, + request_id: None, + quote_message_id: None, + target_message_id: None, + persistent_conversation: None, + }, + )?)) + .await?; + sleep(Duration::from_millis(50)).await; + state.event_tx.send("after-legacy-send".into())?; + assert_eq!(next_text(&mut socket).await?, "after-legacy-send"); + + socket.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + Ok(()) + } + + #[cfg(feature = "teams")] + #[test] + fn teams_hello_advertises_required_send_ack() { + let (event_tx, _event_rx) = broadcast::channel(8); + let mut state = AppState::test_default(event_tx); + state.apply_teams_config(GatewayTeamsConfig { + app_id: Some("app".into()), + app_secret: Some("secret".into()), + allowed_tenants: vec![], + oauth_endpoint: "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token" + .into(), + openid_metadata: "https://login.botframework.com/v1/.well-known/openidconfiguration" + .into(), + webhook_path: "/webhook/teams".into(), + dedupe_ttl_secs: 600, + route_ttl_secs: 3600, + max_route_entries: 10_000, + reactions_enabled: false, + inbound_attachments: true, + conversation_registry_path: None, + conversation_registry_max_entries: 1_000, + conversation_registry_ttl_secs: 365 * 24 * 60 * 60, + }); + let hello = build_gateway_hello( + &state, + &schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["teams".into()], + }, + ); + let teams = hello + .capabilities + .get("teams") + .expect("configured Teams adapter must be advertised"); + assert!(teams.send_ack); + assert!(teams.edit_ack); + assert!(teams.delete_ack); + assert!(teams.supports_target_message_id); + assert!(teams.supports_attachment_materialization); + assert!(!teams.supports_conversation_registry); + assert!(!teams.supports_persistent_conversation_send); + assert!(!teams.supports_reactions); + assert_eq!( + teams.message_limit, + schema::MessageLimit::Utf16Bytes { + max: schema::TEAMS_TEXT_UTF16_BUDGET_BYTES, + } + ); + } + + #[cfg(feature = "teams")] + #[test] + fn teams_persistent_send_capability_requires_registry_and_single_consumer() { + let root = std::fs::canonicalize(std::env::temp_dir()).expect("temp root"); + let directory = root.join(format!( + "openab-gateway-persistent-capability-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir(&directory).expect("registry directory"); + let registry_path = directory.join("registry.json"); + + let (event_tx, _event_rx) = broadcast::channel(8); + let mut state = AppState::test_default(event_tx); + state.apply_teams_config(GatewayTeamsConfig { + app_id: Some("app".into()), + app_secret: Some("secret".into()), + allowed_tenants: vec![], + oauth_endpoint: "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token" + .into(), + openid_metadata: "https://login.botframework.com/v1/.well-known/openidconfiguration" + .into(), + webhook_path: "/webhook/teams".into(), + dedupe_ttl_secs: 600, + route_ttl_secs: 3600, + max_route_entries: 10_000, + reactions_enabled: false, + inbound_attachments: false, + conversation_registry_path: Some(registry_path.to_string_lossy().into_owned()), + conversation_registry_max_entries: 1_000, + conversation_registry_ttl_secs: 365 * 24 * 60 * 60, + }); + let client = schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["teams".into()], + }; + + state + .active_oab_consumers + .store(1, std::sync::atomic::Ordering::Release); + let hello = build_gateway_hello(&state, &client); + let teams = hello.capabilities.get("teams").unwrap(); + assert!(teams.supports_conversation_registry); + assert!(teams.supports_persistent_conversation_send); + + state + .active_oab_consumers + .store(2, std::sync::atomic::Ordering::Release); + let hello = build_gateway_hello(&state, &client); + assert!(!hello.capabilities["teams"].supports_persistent_conversation_send); + + state + .active_oab_consumers + .store(0, std::sync::atomic::Ordering::Release); + let hello = build_gateway_hello(&state, &client); + assert!(!hello.capabilities["teams"].supports_persistent_conversation_send); + + drop(state); + std::fs::remove_dir_all(directory).expect("remove registry directory"); + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn teams_conversation_registration_is_negotiated_scoped_and_acknowledged( + ) -> anyhow::Result<()> { + let connector = MockServer::start().await; + let root = std::fs::canonicalize(std::env::temp_dir())?; + let directory = root.join(format!( + "openab-gateway-registration-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir(&directory)?; + let registry_path = directory.join("registry.json"); + let mut config = teams_test_config(&connector); + config.conversation_registry_path = Some(registry_path.to_string_lossy().into_owned()); + let teams = adapters::teams::TeamsAdapter::new_for_test(config); + teams + .accept_route_for_test( + "https://smba.trafficmanager.net/teams", + "event-registration", + "tenant-1", + "conversation-1", + "activity-secret", + None, + ) + .await?; + + let (event_tx, _event_rx) = broadcast::channel(8); + let mut app_state = AppState::test_default(event_tx); + app_state.teams = Some(teams); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + let registration_command = |request_id: &str, conversation_id: &str| schema::GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-registration".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: conversation_id.into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: String::new(), + attachments: Vec::new(), + }, + command: Some("register_conversation".into()), + request_id: Some(request_id.into()), + quote_message_id: None, + target_message_id: None, + attachment_ref: None, + persistent_conversation: None, + }; + + socket + .send(Message::Text(serde_json::to_string( + ®istration_command("registration-before-hello", "conversation-1"), + )?)) + .await?; + let before_hello: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!( + before_hello.error_code.as_deref(), + Some("capability_not_negotiated") + ); + assert_eq!( + state + .teams + .as_ref() + .and_then(|teams| teams.conversation_registry_counts()) + .map(|counts| counts.active), + Some(0) + ); + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["teams".into()], + }, + )?)) + .await?; + let hello: schema::GatewayHello = serde_json::from_str(&next_text(&mut socket).await?)?; + assert!(hello + .capabilities + .get("teams") + .is_some_and(|capability| capability.supports_conversation_registry)); + + let (mut second_socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 2).await?; + socket + .send(Message::Text(serde_json::to_string( + ®istration_command("registration-unsupported-topology", "conversation-1"), + )?)) + .await?; + let unsupported_topology: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!( + unsupported_topology.error_code.as_deref(), + Some("unsupported_topology") + ); + second_socket.close(None).await?; + wait_for_consumers(&state, 1).await?; + + socket + .send(Message::Text(serde_json::to_string( + ®istration_command("registration-success", "conversation-1"), + )?)) + .await?; + let delivered: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!(delivered.request_id, "registration-success"); + assert!(delivered.success); + assert_eq!(delivered.outcome, Some(schema::WriteOutcomeKind::Delivered)); + assert!(delivered.message_id.is_none()); + assert_eq!( + state + .teams + .as_ref() + .and_then(|teams| teams.conversation_registry_counts()) + .map(|counts| counts.active), + Some(1) + ); + + socket + .send(Message::Text(serde_json::to_string( + ®istration_command("registration-cross-conversation", "other-conversation"), + )?)) + .await?; + let cross_conversation: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!( + cross_conversation.error_code.as_deref(), + Some("conversation_mismatch") + ); + let raw = std::fs::read_to_string(®istry_path)?; + assert!(!raw.contains("event-registration")); + assert!(!raw.contains("activity-secret")); + + socket.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + drop(state); + std::fs::remove_dir_all(directory)?; + Ok(()) + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn teams_materialization_command_returns_one_correlated_attachment() -> anyhow::Result<()> + { + let attachment_server = MockServer::start().await; + let _download = Mock::given(method("GET")) + .and(path("/notes")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"standalone bytes")) + .expect(1) + .mount_as_scoped(&attachment_server) + .await; + let mut config = teams_test_config(&attachment_server); + config.inbound_attachments = true; + let teams = adapters::teams::TeamsAdapter::new_for_test(config); + teams + .accept_text_attachment_route_for_test( + &attachment_server.uri(), + "event-attachment", + "conversation-1", + "activity-1", + "att-opaque", + &format!("{}/notes?sig=private", attachment_server.uri()), + ) + .await?; + + let (event_tx, _event_rx) = broadcast::channel(8); + let mut app_state = AppState::test_default(event_tx); + app_state.teams = Some(teams); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + let materialization_command = |request_id: &str| schema::GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-attachment".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: String::new(), + attachments: Vec::new(), + }, + command: Some("materialize_attachment".into()), + request_id: Some(request_id.into()), + quote_message_id: None, + target_message_id: None, + attachment_ref: Some("att-opaque".into()), + persistent_conversation: None, + }; + socket + .send(Message::Text(serde_json::to_string( + &materialization_command("request-before-hello"), + )?)) + .await?; + let before_hello: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert!(!before_hello.success); + assert_eq!( + before_hello.error_code.as_deref(), + Some("capability_not_negotiated") + ); + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayClientHello { + schema: schema::CLIENT_HELLO_SCHEMA.into(), + protocol_version: schema::GATEWAY_PROTOCOL_VERSION, + client_name: Some("test-core".into()), + requested_platforms: vec!["teams".into()], + }, + )?)) + .await?; + let hello: schema::GatewayHello = serde_json::from_str(&next_text(&mut socket).await?)?; + assert!(hello + .capabilities + .get("teams") + .is_some_and(|capability| capability.supports_attachment_materialization)); + + let (mut second_socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 2).await?; + socket + .send(Message::Text(serde_json::to_string( + &materialization_command("request-unsupported-topology"), + )?)) + .await?; + let unsupported_topology: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!( + unsupported_topology.error_code.as_deref(), + Some("unsupported_topology") + ); + second_socket.close(None).await?; + wait_for_consumers(&state, 1).await?; + + socket + .send(Message::Text(serde_json::to_string( + &materialization_command("request-attachment"), + )?)) + .await?; + let response: schema::GatewayResponse = + serde_json::from_str(&next_text(&mut socket).await?)?; + assert_eq!(response.request_id, "request-attachment"); + assert!(response.success); + let attachment = response + .attachment + .expect("materialized attachment response"); + assert_eq!(attachment.decoded_data()?, b"standalone bytes"); + assert!(attachment.reference.is_none()); + assert!(attachment.path.is_none()); + + socket.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + Ok(()) + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn teams_hello_advertises_reaction_support_only_when_enabled() { + let connector = MockServer::start().await; + let mut config = teams_test_config(&connector); + config.reactions_enabled = true; + let (event_tx, _event_rx) = broadcast::channel(8); + let mut state = AppState::test_default(event_tx); + state.teams = Some(adapters::teams::TeamsAdapter::new_for_test(config)); + + let capabilities = state.gateway_capabilities(); + let teams = capabilities + .get("teams") + .expect("configured Teams capability"); + assert!(teams.supports_reactions); + assert_eq!(teams.status_backend, schema::StatusBackend::Reactions); + } + + #[cfg(feature = "teams")] + #[tokio::test] + async fn teams_structured_outcome_is_emitted_only_when_requested() -> anyhow::Result<()> { + let (event_tx, mut event_rx) = broadcast::channel(8); + let mut reply = schema::GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-1".into(), + platform: "teams".into(), + channel: schema::ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: "hello".into(), + attachments: Vec::new(), + }, + command: None, + request_id: Some("request-1".into()), + quote_message_id: None, + target_message_id: None, + persistent_conversation: None, + }; + let expected_outcomes = [ + schema::WriteOutcome::Delivered { + message_id: Some("activity-1".into()), + }, + schema::WriteOutcome::Rejected { + code: "rate_limited".into(), + message: "retry later".into(), + retry_after_ms: Some(2000), + }, + schema::WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "delivery may have completed".into(), + }, + ]; + for (index, expected) in expected_outcomes.into_iter().enumerate() { + reply.request_id = Some(format!("request-{index}")); + publish_teams_write_outcome(&reply, expected.clone(), &event_tx); + let response: schema::GatewayResponse = serde_json::from_str(&event_rx.recv().await?)?; + assert_eq!(response.request_id, format!("request-{index}")); + assert_eq!(response.write_outcome(), expected); + } + + reply.request_id = None; + publish_teams_write_outcome( + &reply, + schema::WriteOutcome::Rejected { + code: "route_not_found".into(), + message: "missing".into(), + retry_after_ms: None, + }, + &event_tx, + ); + assert!( + event_rx.try_recv().is_err(), + "legacy reply must not receive an ACK" + ); + Ok(()) + } + + #[tokio::test] + async fn legacy_client_can_send_reply_without_hello() -> anyhow::Result<()> { + let (event_tx, _event_rx) = broadcast::channel(8); + let app_state = AppState::test_default(event_tx); + let (addr, state, server) = start_server(app_state).await?; + let url = format!("{}://{addr}/ws", "ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + + let legacy_reply = schema::GatewayReply { + attachment_ref: None, + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt-1".into(), + platform: "unknown".into(), + channel: schema::ReplyChannel { + id: "channel-1".into(), + thread_id: None, + }, + content: schema::Content { + content_type: "text".into(), + text: "hello".into(), + attachments: Vec::new(), + }, + command: None, + request_id: None, + quote_message_id: None, + target_message_id: None, + persistent_conversation: None, + }; + socket + .send(Message::Text(serde_json::to_string(&legacy_reply)?)) + .await?; + + state.event_tx.send("legacy-event".into())?; + assert_eq!(next_text(&mut socket).await?, "legacy-event"); + + socket.close(None).await?; + wait_for_consumers(&state, 0).await?; + server.abort(); + Ok(()) + } +} + /// Render a channel id for logs, hashing it when it is an ACP channel or session id. /// /// An ACP `channel_id` is `acp_` and the session id is `sess_`, so the two are diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index 0470a9b3d..d1d28b2c1 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -1,4 +1,6 @@ +use base64::Engine; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // --- Event schema (ADR openab.gateway.event.v1) --- @@ -14,6 +16,44 @@ pub struct GatewayEvent { pub content: Content, pub mentions: Vec, pub message_id: String, + /// Authenticated platform scope used for trust decisions. Additive and + /// optional so old Gateway/Core peers retain their legacy behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// Receiving bot identity, distinct from the human sender. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recipient: Option, + /// Structured mention entities. `mentions` remains the cross-platform ID + /// list; this richer form lets Core remove only the receiving bot's text. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mention_entities: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct GatewayScope { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_id: Option, + pub conversation_type: String, + pub trust_scope_id: String, + pub is_dm: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct RecipientInfo { + pub id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct MentionInfo { + pub id: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub text: String, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -47,6 +87,10 @@ pub struct Attachment { pub attachment_type: String, // "image", "text_file", "audio" pub filename: String, pub mime_type: String, + /// Gateway-local opaque reference. Core may request materialization only + /// after trust admission and only when the peer advertises support. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, /// Base64-encoded data (deprecated — use `path` for colocate mode). /// Kept for backward compatibility; Core prefers `path` when present. #[serde(default, skip_serializing_if = "String::is_empty")] @@ -77,6 +121,10 @@ pub struct Attachment { } impl Attachment { + pub fn decoded_data(&self) -> Result, base64::DecodeError> { + base64::engine::general_purpose::STANDARD.decode(&self.data) + } + /// Create a rejected attachment carrying a human-readable status reason. /// `size` should be the original file size in bytes (0 if unknown). pub fn rejected( @@ -90,6 +138,7 @@ impl Attachment { attachment_type: attachment_type.into(), filename: filename.into(), mime_type: mime_type.into(), + reference: None, data: String::new(), size, path: None, @@ -98,14 +147,150 @@ impl Attachment { } } +// --- Gateway protocol negotiation and capability schema --- + +pub const CLIENT_HELLO_SCHEMA: &str = "openab.gateway.client_hello.v1"; +pub const GATEWAY_HELLO_SCHEMA: &str = "openab.gateway.hello.v1"; +pub const GATEWAY_PROTOCOL_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize)] +pub struct GatewayEnvelope { + pub schema: String, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StreamingMode { + #[default] + Disabled, + Edit, + Native, +} + +/// Conservative text budget from Microsoft's recommended 80 KB Teams +/// implementation target. Decimal bytes are intentional. +pub const TEAMS_TEXT_UTF16_BUDGET_BYTES: usize = 80_000; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", rename_all = "snake_case")] +pub enum MessageLimit { + Characters { max: usize }, + Bytes { max: usize }, + Utf16Bytes { max: usize }, + Unlimited, +} + +impl Default for MessageLimit { + fn default() -> Self { + Self::Characters { max: 4096 } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StatusBackend { + #[default] + None, + Reactions, + Assistant, + Typing, + Message, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdapterCapabilities { + pub send_ack: bool, + pub edit_ack: bool, + pub delete_ack: bool, + /// Whether command targets use the additive `target_message_id` field. + /// False peers require the legacy `reply_to = target` fallback. + pub supports_target_message_id: bool, + /// Native reactions may coexist with a different transient status backend. + #[serde(default)] + pub supports_reactions: bool, + /// Resolve opaque inbound attachment references after Core admission. + #[serde(default)] + pub supports_attachment_materialization: bool, + /// Persist an authenticated Teams route only after Core trust admission. + #[serde(default)] + pub supports_conversation_registry: bool, + /// Resolve an exact durable Teams conversation for proactive writes. + #[serde(default)] + pub supports_persistent_conversation_send: bool, + pub can_edit: bool, + pub can_delete: bool, + pub streaming_mode: StreamingMode, + pub show_streaming_placeholder: bool, + pub message_limit: MessageLimit, + pub status_backend: StatusBackend, +} + +impl Default for AdapterCapabilities { + fn default() -> Self { + Self { + send_ack: false, + edit_ack: false, + delete_ack: false, + supports_target_message_id: false, + supports_reactions: false, + supports_attachment_materialization: false, + supports_conversation_registry: false, + supports_persistent_conversation_send: false, + can_edit: false, + can_delete: false, + streaming_mode: StreamingMode::Disabled, + show_streaming_placeholder: true, + message_limit: MessageLimit::default(), + status_backend: StatusBackend::None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GatewayClientHello { + pub schema: String, + pub protocol_version: u32, + #[serde(default)] + pub client_name: Option, + #[serde(default)] + pub requested_platforms: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GatewayHello { + pub schema: String, + pub protocol_version: u32, + #[serde(default)] + pub capabilities: HashMap, + pub topology: GatewayTopology, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GatewayTopology { + pub active_consumers: usize, + pub supported: bool, + pub delivery_mode: String, +} + // --- Reply schema (ADR openab.gateway.reply.v1) --- +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PersistentConversationTarget { + pub tenant_id: String, + pub bot_framework_channel_id: String, + pub conversation_id: String, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct GatewayReply { pub schema: String, pub reply_to: String, pub platform: String, pub channel: ReplyChannel, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persistent_conversation: Option, pub content: Content, #[serde(default)] pub command: Option, @@ -117,6 +302,14 @@ pub struct GatewayReply { /// If quoting fails, the gateway MUST fall back to sending without quoting. #[serde(default)] pub quote_message_id: Option, + /// Platform message targeted by a command such as edit or delete. + /// `reply_to` remains the origin event correlation for peers that advertise + /// support; old peers continue to place the command target in `reply_to`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_message_id: Option, + /// Opaque Gateway-local inbound attachment selected for materialization. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment_ref: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -125,7 +318,36 @@ pub struct ReplyChannel { pub thread_id: Option, } -/// Response from gateway back to OAB for commands (e.g. create_topic) +/// Stable wire discriminator for additive write-outcome fields. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WriteOutcomeKind { + Delivered, + Rejected, + Unknown, +} + +/// Internal result of a platform write. `Unknown` prevents unsafe retries when +/// a timed-out POST may already have reached the platform. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WriteOutcome { + Delivered { + message_id: Option, + }, + Rejected { + code: String, + message: String, + retry_after_ms: Option, + }, + Unknown { + code: String, + message: String, + }, +} + +/// Response from gateway back to OAB for commands and acknowledged writes. +/// The legacy fields remain required; outcome metadata is additive so old peers +/// can ignore it and new peers can distinguish rejection from uncertainty. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct GatewayResponse { pub schema: String, @@ -134,6 +356,131 @@ pub struct GatewayResponse { pub thread_id: Option, pub message_id: Option, pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, + /// Normalized result for `materialize_attachment`; absent for writes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment: Option, +} + +impl GatewayResponse { + pub fn from_write_outcome(request_id: impl Into, outcome: WriteOutcome) -> Self { + let request_id = request_id.into(); + match outcome { + WriteOutcome::Delivered { message_id } => Self { + schema: "openab.gateway.response.v1".into(), + request_id, + success: true, + thread_id: None, + message_id, + error: None, + outcome: Some(WriteOutcomeKind::Delivered), + error_code: None, + retry_after_ms: None, + attachment: None, + }, + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => Self { + schema: "openab.gateway.response.v1".into(), + request_id, + success: false, + thread_id: None, + message_id: None, + error: Some(message), + outcome: Some(WriteOutcomeKind::Rejected), + error_code: Some(code), + retry_after_ms, + attachment: None, + }, + WriteOutcome::Unknown { code, message } => Self { + schema: "openab.gateway.response.v1".into(), + request_id, + success: false, + thread_id: None, + message_id: None, + error: Some(message), + outcome: Some(WriteOutcomeKind::Unknown), + error_code: Some(code), + retry_after_ms: None, + attachment: None, + }, + } + } + + pub fn from_attachment(request_id: impl Into, attachment: Attachment) -> Self { + Self { + schema: "openab.gateway.response.v1".into(), + request_id: request_id.into(), + success: true, + thread_id: None, + message_id: None, + error: None, + outcome: None, + error_code: None, + retry_after_ms: None, + attachment: Some(attachment), + } + } + + pub fn from_command_error( + request_id: impl Into, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + schema: "openab.gateway.response.v1".into(), + request_id: request_id.into(), + success: false, + thread_id: None, + message_id: None, + error: Some(message.into()), + outcome: None, + error_code: Some(code.into()), + retry_after_ms: None, + attachment: None, + } + } + + pub fn write_outcome(&self) -> WriteOutcome { + match self.outcome { + Some(WriteOutcomeKind::Delivered) => WriteOutcome::Delivered { + message_id: self.message_id.clone(), + }, + Some(WriteOutcomeKind::Rejected) => WriteOutcome::Rejected { + code: self.error_code.clone().unwrap_or_else(|| "rejected".into()), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway rejected write".into()), + retry_after_ms: self.retry_after_ms, + }, + Some(WriteOutcomeKind::Unknown) => WriteOutcome::Unknown { + code: self.error_code.clone().unwrap_or_else(|| "unknown".into()), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway write outcome is unknown".into()), + }, + None if self.success => WriteOutcome::Delivered { + message_id: self.message_id.clone(), + }, + None => WriteOutcome::Rejected { + code: "legacy_failure".into(), + message: self + .error + .clone() + .unwrap_or_else(|| "gateway reported failure".into()), + retry_after_ms: None, + }, + } + } } impl GatewayEvent { @@ -160,6 +507,398 @@ impl GatewayEvent { }, mentions, message_id: message_id.into(), + scope: None, + recipient: None, + mention_entities: Vec::new(), } } } + +#[cfg(test)] +mod protocol_tests { + use super::*; + + #[test] + fn legacy_response_deserializes_without_outcome_fields() { + let response: GatewayResponse = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.response.v1", + "request_id": "req-1", + "success": true, + "thread_id": null, + "message_id": "activity-1", + "error": null + })) + .unwrap(); + + assert_eq!( + response.write_outcome(), + WriteOutcome::Delivered { + message_id: Some("activity-1".into()) + } + ); + let encoded = serde_json::to_value(response).unwrap(); + assert!(encoded.get("outcome").is_none()); + assert!(encoded.get("error_code").is_none()); + assert!(encoded.get("retry_after_ms").is_none()); + } + + #[test] + fn structured_write_outcomes_round_trip() { + let outcomes = [ + WriteOutcome::Delivered { + message_id: Some("activity-2".into()), + }, + WriteOutcome::Rejected { + code: "rate_limited".into(), + message: "retry later".into(), + retry_after_ms: Some(750), + }, + WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "delivery may have completed".into(), + }, + ]; + + for (index, expected) in outcomes.into_iter().enumerate() { + let response = + GatewayResponse::from_write_outcome(format!("req-{index}"), expected.clone()); + let json = serde_json::to_string(&response).unwrap(); + let decoded: GatewayResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.write_outcome(), expected); + } + } + + #[test] + fn structured_response_remains_decodable_by_legacy_peer() { + #[derive(serde::Deserialize)] + struct LegacyResponse { + schema: String, + request_id: String, + success: bool, + message_id: Option, + error: Option, + } + + let response = GatewayResponse::from_write_outcome( + "req-legacy", + WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "delivery may have completed".into(), + }, + ); + let legacy: LegacyResponse = + serde_json::from_str(&serde_json::to_string(&response).unwrap()).unwrap(); + assert_eq!(legacy.schema, "openab.gateway.response.v1"); + assert_eq!(legacy.request_id, "req-legacy"); + assert!(!legacy.success); + assert!(legacy.message_id.is_none()); + assert_eq!(legacy.error.as_deref(), Some("delivery may have completed")); + } + + #[test] + fn command_target_field_is_additive_and_legacy_decodable() -> anyhow::Result<()> { + #[derive(serde::Deserialize)] + struct LegacyReply { + reply_to: String, + command: Option, + } + + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "event-1".into(), + platform: "teams".into(), + channel: ReplyChannel { + id: "conversation-1".into(), + thread_id: None, + }, + persistent_conversation: None, + content: Content { + content_type: "text".into(), + text: "updated".into(), + attachments: Vec::new(), + }, + command: Some("edit_message".into()), + request_id: Some("request-1".into()), + quote_message_id: None, + target_message_id: Some("activity-1".into()), + attachment_ref: None, + }; + let json = serde_json::to_string(&reply)?; + let legacy: LegacyReply = serde_json::from_str(&json)?; + assert_eq!(legacy.reply_to, "event-1"); + assert_eq!(legacy.command.as_deref(), Some("edit_message")); + + let decoded_without_target: GatewayReply = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.reply.v1", + "reply_to": "legacy-activity", + "platform": "teams", + "channel": { "id": "conversation-1", "thread_id": null }, + "content": { "type": "text", "text": "updated", "attachments": [] }, + "command": "edit_message", + "request_id": null, + "quote_message_id": null + }))?; + assert!(decoded_without_target.target_message_id.is_none()); + assert!(decoded_without_target.attachment_ref.is_none()); + assert_eq!(decoded_without_target.reply_to, "legacy-activity"); + Ok(()) + } + + #[test] + fn persistent_conversation_target_is_additive_and_closed() -> anyhow::Result<()> { + #[derive(serde::Deserialize)] + struct LegacyReply { + platform: String, + channel: ReplyChannel, + } + + let value = serde_json::json!({ + "schema": "openab.gateway.reply.v1", + "reply_to": "", + "platform": "teams", + "channel": { "id": "conversation-1", "thread_id": null }, + "persistent_conversation": { + "tenant_id": "tenant-1", + "bot_framework_channel_id": "msteams", + "conversation_id": "conversation-1" + }, + "content": { "type": "text", "text": "scheduled", "attachments": [] }, + "command": null, + "request_id": "request-1" + }); + let decoded: GatewayReply = serde_json::from_value(value.clone())?; + assert_eq!( + decoded.persistent_conversation, + Some(PersistentConversationTarget { + tenant_id: "tenant-1".into(), + bot_framework_channel_id: "msteams".into(), + conversation_id: "conversation-1".into(), + }) + ); + let legacy: LegacyReply = serde_json::from_value(value.clone())?; + assert_eq!(legacy.platform, "teams"); + assert_eq!(legacy.channel.id, "conversation-1"); + + let mut unknown = value; + unknown["persistent_conversation"]["service_url"] = + serde_json::Value::String("https://example.invalid".into()); + assert!(serde_json::from_value::(unknown).is_err()); + + let old: GatewayReply = serde_json::from_value(serde_json::json!({ + "schema": "openab.gateway.reply.v1", + "reply_to": "event-1", + "platform": "teams", + "channel": { "id": "conversation-1", "thread_id": null }, + "content": { "type": "text", "text": "reactive", "attachments": [] } + }))?; + assert!(old.persistent_conversation.is_none()); + Ok(()) + } + + #[test] + fn attachment_materialization_fields_are_additive_and_bounded_envelopes() -> anyhow::Result<()> + { + #[derive(serde::Deserialize)] + struct LegacyAttachment { + filename: String, + mime_type: String, + #[serde(default)] + data: String, + } + + let metadata = Attachment { + attachment_type: "image".into(), + filename: "image.png".into(), + mime_type: "image/png".into(), + reference: Some("att_opaque".into()), + data: String::new(), + size: 0, + path: None, + status: None, + }; + let metadata_json = serde_json::to_string(&metadata)?; + let legacy: LegacyAttachment = serde_json::from_str(&metadata_json)?; + assert_eq!(legacy.filename, "image.png"); + assert_eq!(legacy.mime_type, "image/png"); + assert!(legacy.data.is_empty()); + assert!(!metadata_json.contains("http")); + + let materialized = Attachment { + reference: None, + data: "aGVsbG8=".into(), + size: 5, + ..metadata + }; + let response = GatewayResponse::from_attachment("request-1", materialized); + let decoded: GatewayResponse = serde_json::from_str(&serde_json::to_string(&response)?)?; + let attachment = decoded + .attachment + .ok_or_else(|| anyhow::anyhow!("materialized attachment is missing"))?; + assert_eq!(attachment.decoded_data()?, b"hello"); + + let old_wire: Attachment = serde_json::from_value(serde_json::json!({ + "type": "image", + "filename": "legacy.png", + "mime_type": "image/png", + "data": "", + "size": 0, + "path": null, + "status": null + }))?; + assert!(old_wire.reference.is_none()); + Ok(()) + } + + #[test] + fn typed_scope_and_mentions_are_additive_to_gateway_events() -> anyhow::Result<()> { + #[derive(serde::Deserialize)] + struct LegacyEvent { + schema: String, + event_id: String, + mentions: Vec, + message_id: String, + } + + let mut event = GatewayEvent::new( + "teams", + ChannelInfo { + id: "conversation-1".into(), + channel_type: "channel".into(), + thread_id: None, + }, + SenderInfo { + id: "user-1".into(), + name: "Alice".into(), + display_name: "Alice".into(), + is_bot: false, + }, + "OpenAB hello", + "activity-1", + vec!["bot-1".into()], + ); + event.scope = Some(GatewayScope { + tenant_id: Some("tenant-1".into()), + team_id: Some("team-1".into()), + channel_id: Some("channel-1".into()), + conversation_type: "channel".into(), + trust_scope_id: "teams:tenant-1:team:team-1:channel:channel-1".into(), + is_dm: false, + }); + event.recipient = Some(RecipientInfo { + id: "bot-1".into(), + name: "OpenAB".into(), + }); + event.mention_entities = vec![MentionInfo { + id: "bot-1".into(), + text: "OpenAB".into(), + }]; + + let json = serde_json::to_string(&event)?; + let legacy: LegacyEvent = serde_json::from_str(&json)?; + assert_eq!(legacy.schema, "openab.gateway.event.v1"); + assert_eq!(legacy.event_id, event.event_id); + assert_eq!(legacy.mentions, vec!["bot-1"]); + assert_eq!(legacy.message_id, "activity-1"); + + let old_wire = serde_json::json!({ + "schema": "openab.gateway.event.v1", + "event_id": "event-legacy", + "timestamp": "2026-08-07T00:00:00Z", + "platform": "teams", + "event_type": "message", + "channel": { "id": "conversation-1", "type": "personal", "thread_id": null }, + "sender": { "id": "user-1", "name": "Alice", "display_name": "Alice", "is_bot": false }, + "content": { "type": "text", "text": "hello" }, + "mentions": [], + "message_id": "activity-legacy" + }); + let decoded: GatewayEvent = serde_json::from_value(old_wire)?; + assert!(decoded.scope.is_none()); + assert!(decoded.recipient.is_none()); + assert!(decoded.mention_entities.is_empty()); + Ok(()) + } + + #[test] + fn reaction_support_capability_is_additive_for_old_peers() { + #[derive(serde::Deserialize)] + struct LegacyCapabilities { + status_backend: StatusBackend, + } + + let modern = AdapterCapabilities { + supports_reactions: true, + status_backend: StatusBackend::Reactions, + ..AdapterCapabilities::default() + }; + let json = serde_json::to_string(&modern).unwrap(); + let legacy: LegacyCapabilities = serde_json::from_str(&json).unwrap(); + assert_eq!(legacy.status_backend, StatusBackend::Reactions); + + let old_wire = serde_json::json!({ "status_backend": "reactions" }); + let decoded: AdapterCapabilities = serde_json::from_value(old_wire).unwrap(); + assert!(!decoded.supports_reactions); + assert_eq!(decoded.status_backend, StatusBackend::Reactions); + } + + #[test] + fn conversation_registry_capability_is_additive_and_fail_closed() { + #[derive(serde::Deserialize)] + struct LegacyCapabilities { + send_ack: bool, + } + + let modern = AdapterCapabilities { + send_ack: true, + supports_conversation_registry: true, + supports_persistent_conversation_send: true, + ..AdapterCapabilities::default() + }; + let json = serde_json::to_string(&modern).unwrap(); + let legacy: LegacyCapabilities = serde_json::from_str(&json).unwrap(); + assert!(legacy.send_ack); + + let old_wire = serde_json::json!({ "send_ack": true }); + let decoded: AdapterCapabilities = serde_json::from_value(old_wire).unwrap(); + assert!(!decoded.supports_conversation_registry); + assert!(!decoded.supports_persistent_conversation_send); + } + + #[test] + fn utf16_message_limit_round_trips_without_protocol_change() -> anyhow::Result<()> { + let value = MessageLimit::Utf16Bytes { + max: TEAMS_TEXT_UTF16_BUDGET_BYTES, + }; + let json = serde_json::to_value(value)?; + assert_eq!( + json, + serde_json::json!({ + "unit": "utf16_bytes", + "max": 80_000, + }) + ); + assert_eq!(serde_json::from_value::(json)?, value); + Ok(()) + } + + #[test] + fn missing_capability_fields_default_fail_closed() { + let capabilities: AdapterCapabilities = serde_json::from_str("{}").unwrap(); + assert!(!capabilities.send_ack); + assert!(!capabilities.edit_ack); + assert!(!capabilities.delete_ack); + assert!(!capabilities.supports_target_message_id); + assert!(!capabilities.supports_reactions); + assert!(!capabilities.supports_attachment_materialization); + assert!(!capabilities.supports_conversation_registry); + assert!(!capabilities.supports_persistent_conversation_send); + assert!(!capabilities.can_edit); + assert!(!capabilities.can_delete); + assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); + assert_eq!(capabilities.status_backend, StatusBackend::None); + assert_eq!( + capabilities.message_limit, + MessageLimit::Characters { max: 4096 } + ); + } +} diff --git a/crates/openab-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index eeefc8d8c..3e7f7950f 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -103,6 +103,20 @@ const COVERED: &[&str] = &[ "TEAMS_OAUTH_ENDPOINT", "TEAMS_OPENID_METADATA", "TEAMS_WEBHOOK_PATH", + "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", + "TEAMS_DEDUPE_TTL_SECS", + "TEAMS_ROUTE_TTL_SECS", + "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", + "TEAMS_CONVERSATION_REGISTRY_PATH", + "TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES", + "TEAMS_CONVERSATION_REGISTRY_TTL_SECS", "TEAMS_ALLOW_ALL_USERS", "TEAMS_ALLOWED_USERS", // lineworks diff --git a/crates/platform-schema/Cargo.lock b/crates/platform-schema/Cargo.lock index d709abdd4..7c27b64a3 100644 --- a/crates/platform-schema/Cargo.lock +++ b/crates/platform-schema/Cargo.lock @@ -2,18 +2,291 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -21,7 +294,86 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", ] [[package]] @@ -30,14 +382,150 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "platform-schema" version = "0.1.0" dependencies = [ + "jsonschema", "serde", + "serde_json", "toml", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -56,6 +544,99 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -83,7 +664,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] @@ -95,6 +689,18 @@ dependencies = [ "serde", ] +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.118" @@ -106,6 +712,38 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.8.23" @@ -147,12 +785,106 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "winnow" version = "0.7.15" @@ -161,3 +893,118 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/platform-schema/Cargo.toml b/crates/platform-schema/Cargo.toml index 8737f3d60..bf67733d5 100644 --- a/crates/platform-schema/Cargo.toml +++ b/crates/platform-schema/Cargo.toml @@ -14,3 +14,7 @@ description = "Authoritative types + conformance tests for docs/platforms/schema [dependencies] serde = { version = "1", features = ["derive"] } toml = "0.8" + +[dev-dependencies] +jsonschema = { version = "0.46", default-features = false } +serde_json = "1" diff --git a/crates/platform-schema/testdata/MicrosoftTeams.v1.25.schema.json b/crates/platform-schema/testdata/MicrosoftTeams.v1.25.schema.json new file mode 100644 index 000000000..858b3f853 --- /dev/null +++ b/crates/platform-schema/testdata/MicrosoftTeams.v1.25.schema.json @@ -0,0 +1,3313 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "format": "uri" + }, + "manifestVersion": { + "type": "string", + "description": "The version of the schema this manifest is using. This schema version supports extending Teams apps to other parts of the Microsoft 365 ecosystem. More info at https://aka.ms/extendteamsapps.", + "const": "1.25" + }, + "version": { + "type": "string", + "description": "The version of the app. Changes to your manifest should cause a version change. This version string must follow the semver standard (http://semver.org).", + "maxLength": 256 + }, + "id": { + "$ref": "#/definitions/guid", + "description": "A unique identifier for this app. This id must be a GUID." + }, + "localizationInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultLanguageTag": { + "$ref": "#/definitions/languageTag", + "description": "The language tag of the strings in this top level manifest file.", + "default": "en-us" + }, + "defaultLanguageFile": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a the .json file containing strings in the default language." + }, + "additionalLanguages": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "languageTag": { + "$ref": "#/definitions/languageTag", + "description": "The language tag of the strings in the provided file." + }, + "file": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a the .json file containing the translated strings." + } + }, + "required": [ + "languageTag", + "file" + ] + } + } + }, + "required": [ + "defaultLanguageTag" + ] + }, + "developer": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The display name for the developer.", + "maxLength": 32 + }, + "mpnId": { + "type": "string", + "description": "The Microsoft Partner Network ID that identifies the partner organization building the app. This field is not required, and should only be used if you are already part of the Microsoft Partner Network. More info at https://aka.ms/partner", + "maxLength": 10 + }, + "websiteUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to the page that provides support information for the app." + }, + "privacyUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to the page that provides privacy information for the app." + }, + "termsOfUseUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to the page that provides the terms of use for the app." + } + }, + "required": [ + "name", + "websiteUrl", + "privacyUrl", + "termsOfUseUrl" + ] + }, + "name": { + "type": "object", + "additionalProperties": false, + "properties": { + "short": { + "type": "string", + "description": "A short display name for the app.", + "maxLength": 30 + }, + "full": { + "type": "string", + "description": "The full name of the app, used if the full app name exceeds 30 characters.", + "maxLength": 100 + } + }, + "required": [ + "short" + ] + }, + "description": { + "type": "object", + "additionalProperties": false, + "properties": { + "short": { + "type": "string", + "description": "A short description of the app used when space is limited. Maximum length is 80 characters.", + "maxLength": 80 + }, + "full": { + "type": "string", + "description": "The full description of the app. Maximum length is 4000 characters.", + "maxLength": 4000 + } + }, + "required": [ + "short", + "full" + ] + }, + "icons": { + "type": "object", + "additionalProperties": false, + "properties": { + "outline": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a transparent PNG outline icon. The border color needs to be white. Size 32x32." + }, + "color": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a full color PNG icon. Size 192x192." + }, + "color32x32": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a full color PNG icon with transparent background. Size 32x32." + } + }, + "required": [ + "outline", + "color" + ] + }, + "accentColor": { + "$ref": "#/definitions/hexColor", + "description": "A color to use in conjunction with the icon. The value must be a valid HTML color code starting with '#', for example `#4464ee`." + }, + "configurableTabs": { + "type": "array", + "description": "These are tabs users can optionally add to their channels and 1:1 or group chats and require extra configuration before they are added. Configurable tabs are not supported in the personal scope. Currently only one configurable tab per app is supported.", + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the tab. This id must be unique within the app manifest.", + "maxLength": 64 + }, + "configurationUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to use when configuring the tab." + }, + "canUpdateConfiguration": { + "type": "boolean", + "description": "A value indicating whether an instance of the tab's configuration can be updated by the user after creation.", + "default": true + }, + "scopes": { + "type": "array", + "description": "Specifies whether the tab offers an experience in the context of a channel in a team, in a 1:1 or group chat, or in an experience scoped to an individual user alone. These options are non-exclusive. Currently, configurable tabs are only supported in the teams and groupchats scopes.", + "maxItems": 2, + "items": { + "enum": [ + "team", + "groupChat" + ] + } + }, + "meetingSurfaces": { + "type": "array", + "description": "The set of meetingSurfaceItem scopes that a tab belong to", + "maxItems": 2, + "items": { + "enum": [ + "sidePanel", + "stage" + ] + } + }, + "context": { + "type": "array", + "description": "The set of contextItem scopes that a tab belong to", + "maxItems": 7, + "items": { + "enum": [ + "personalTab", + "channelTab", + "privateChatTab", + "meetingChatTab", + "meetingDetailsTab", + "meetingSidePanel", + "meetingStage" + ] + } + }, + "sharePointPreviewImage": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a tab preview image for use in SharePoint. Size 1024x768." + }, + "supportedSharePointHosts": { + "type": "array", + "description": "Defines how your tab will be made available in SharePoint.", + "maxItems": 2, + "uniqueItems": true, + "items": { + "enum": [ + "sharePointFullPage", + "sharePointWebPart" + ] + } + } + }, + "required": [ + "configurationUrl", + "scopes" + ] + } + }, + "staticTabs": { + "type": "array", + "description": "A set of tabs that may be 'pinned' by default, without the user adding them manually. Static tabs declared in personal scope are always pinned to the app's personal experience. Static tabs do not currently support the 'teams' scope.", + "maxItems": 16, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "entityId": { + "type": "string", + "description": "A unique identifier for the entity which the tab displays.", + "maxLength": 64 + }, + "name": { + "type": "string", + "description": "The display name of the tab.", + "maxLength": 128 + }, + "contentUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url which points to the entity UI to be displayed in the canvas." + }, + "contentBotId": { + "$ref": "#/definitions/guid", + "description": "The Microsoft App ID specified for the bot in the Bot Framework portal (https://dev.botframework.com/bots)" + }, + "websiteUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to point at if a user opts to view in a browser." + }, + "searchUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to direct a user's search queries." + }, + "scopes": { + "type": "array", + "description": "Specifies whether the tab offers an experience in the context of a channel in a team, or an experience scoped to an individual user alone or group chat. These options are non-exclusive. Currently static tabs are only supported in the 'personal' scope.", + "maxItems": 3, + "items": { + "enum": [ + "team", + "personal", + "groupChat" + ] + } + }, + "context": { + "type": "array", + "description": "The set of contextItem scopes that a tab belong to", + "maxItems": 8, + "items": { + "enum": [ + "personalTab", + "channelTab", + "privateChatTab", + "meetingChatTab", + "meetingDetailsTab", + "meetingSidePanel", + "meetingStage", + "teamLevelApp" + ] + } + }, + "requirementSet": { + "$ref": "#/definitions/elementRequirementSet" + } + }, + "required": [ + "entityId", + "scopes" + ] + } + }, + "bots": { + "type": "array", + "description": "The set of bots for this app. Currently only one bot per app is supported.", + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "botId": { + "$ref": "#/definitions/guid", + "description": "The Microsoft App ID specified for the bot in the Bot Framework portal (https://dev.botframework.com/bots)" + }, + "configuration": { + "type": "object", + "additionalProperties": false, + "properties": { + "team": { + "type": "object", + "additionalProperties": false, + "properties": { + "fetchTask": { + "$ref": "#/properties/composeExtensions/items/properties/commands/items/properties/fetchTask" + }, + "taskInfo": { + "$ref": "#/properties/composeExtensions/items/properties/commands/items/properties/taskInfo" + } + } + }, + "groupChat": { + "$ref": "#/properties/bots/items/properties/configuration/properties/team" + } + } + }, + "needsChannelSelector": { + "type": "boolean", + "description": "This value describes whether or not the bot utilizes a user hint to add the bot to a specific channel.", + "default": false + }, + "isNotificationOnly": { + "type": "boolean", + "description": "A value indicating whether or not the bot is a one-way notification only bot, as opposed to a conversational bot.", + "default": false + }, + "supportsFiles": { + "type": "boolean", + "description": "A value indicating whether the bot supports uploading/downloading of files.", + "default": false + }, + "supportsCalling": { + "type": "boolean", + "description": "A value indicating whether the bot supports audio calling.", + "default": false + }, + "supportsVideo": { + "type": "boolean", + "description": "A value indicating whether the bot supports video calling.", + "default": false + }, + "scopes": { + "type": "array", + "description": "Specifies whether the bot offers an experience in the context of a channel in a team, in a group chat (groupChat), an experience scoped to an individual user alone (personal) OR within Copilot surfaces. These options are non-exclusive.", + "maxItems": 4, + "items": { + "enum": [ + "team", + "personal", + "groupChat", + "copilot" + ] + } + }, + "commandLists": { + "type": "array", + "maxItems": 3, + "description": "The list of commands that the bot supplies, including their usage, description, and the scope for which the commands are valid. A separate command list should be used for each scope.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "scopes": { + "type": "array", + "description": "Specifies the scopes for which the command list is valid", + "maxItems": 4, + "items": { + "enum": [ + "team", + "personal", + "groupChat", + "copilot" + ] + } + }, + "commands": { + "type": "array", + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "The bot command name", + "maxLength": 128 + }, + "description": { + "type": "string", + "description": "A simple text description or an example of the command syntax and its arguments.", + "maxLength": 4000 + } + }, + "required": [ + "title", + "description" + ] + } + } + }, + "required": [ + "scopes", + "commands" + ] + } + }, + "requirementSet": { + "$ref": "#/definitions/elementRequirementSet" + }, + "registrationInfo": { + "description": "System‑generated metadata. This information is maintained by Microsoft services and must not be modified manually.", + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ "standard", "microsoftCopilotStudio", "onedriveSharepoint" ], + "description": "The partner source through which the bot is registered. System‑generated metadata. This information is maintained by Microsoft services and must not be modified manually." + }, + "environment": { + "type": "string", + "description": "A Power Platform environment that serves as a container for building apps under a Microsoft 365 tenant and can only be accessed by users within that tenant. System‑generated metadata. This information is maintained by Microsoft services and must not be modified manually.", + "maxLength": 128 + }, + "schemaName": { + "type": "string", + "description": "The Copilot Studio copilot schema name. System‑generated metadata. This information is maintained by Microsoft services and must not be modified manually.", + "maxLength": 128 + }, + "clusterCategory": { + "type": "string", + "description": "The core services cluster category for Copilot Studio copilots. System‑generated metadata. This information is maintained by Microsoft services and must not be modified manually.", + "maxLength": 128 + } + }, + "required": [ "source" ], + "additionalProperties": false + } + }, + "required": [ + "botId", + "scopes" + ] + } + }, + "connectors": { + "type": "array", + "description": "The set of Office365 connectors for this app. Currently only one connector per app is supported.", + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "connectorId": { + "type": "string", + "description": "A unique identifier for the connector which matches its ID in the Connectors Developer Portal.", + "maxLength": 64 + }, + "configurationUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to use for configuring the connector using the inline configuration experience." + }, + "scopes": { + "type": "array", + "description": "Specifies whether the connector offers an experience in the context of a channel in a team, or an experience scoped to an individual user alone. Currently, only the team scope is supported.", + "maxItems": 1, + "items": { + "enum": [ + "team" + ] + } + } + }, + "required": [ + "connectorId", + "scopes" + ] + } + }, + "subscriptionOffer": { + "type": "object", + "description": "Subscription offer associated with this app.", + "properties": { + "offerId": { + "type": "string", + "description": "A unique identifier for the Commercial Marketplace Software as a Service Offer.", + "maxLength": 2048 + } + }, + "required": [ + "offerId" + ], + "additionalProperties": false + }, + "composeExtensions": { + "type": "array", + "description": "The set of compose extensions for this app. Currently only one compose extension per app is supported.", + "maxItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for the compose extension.", + "maxLength": 64 + }, + "botId": { + "$ref": "#/definitions/guid", + "description": "The Microsoft App ID specified for the bot powering the compose extension in the Bot Framework portal (https://dev.botframework.com/bots)" + }, + "composeExtensionType": { + "type": "string", + "enum": [ + "botBased", + "apiBased" + ], + "description": "Type of the compose extension.", + "default": "botBased" + }, + "authorization": { + "type": "object", + "description": "Object capturing authorization information.", + "properties": { + "authType": { + "type": "string", + "enum": [ + "none", + "apiSecretServiceAuth", + "microsoftEntra" + ], + "description": "Enum of possible authentication types." + }, + "microsoftEntraConfiguration": { + "type": "object", + "description": "Object capturing details needed to do single aad auth flow. It will be only present when auth type is entraId.", + "properties": { + "supportsSingleSignOn": { + "type": "boolean", + "default": false, + "description": "Boolean indicating whether single sign on is configured for the app." + } + }, + "additionalProperties": false + }, + "apiSecretServiceAuthConfiguration": { + "type": "object", + "description": "Object capturing details needed to do service auth. It will be only present when auth type is apiSecretServiceAuth.", + "properties": { + "apiSecretRegistrationId": { + "type": "string", + "description": "Registration id returned when developer submits the api key through Developer Portal.", + "maxLength": 128 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "apiSpecificationFile": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to the api specification file in the manifest package." + }, + "canUpdateConfiguration": { + "type": [ "boolean", "null" ], + "description": "A value indicating whether the configuration of a compose extension can be updated by the user.", + "default": "null" + }, + "commands": { + "type": "array", + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Id of the command.", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "query", + "action" + ], + "description": "Type of the command", + "default": "query" + }, + "samplePrompts": { + "type": "array", + "maxItems": 5, + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "description": "This string will hold the sample prompt", + "maxLength": 128 + } + }, + "required": [ + "text" + ] + } + }, + "apiResponseRenderingTemplateFile": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path for api response rendering template file." + }, + "context": { + "type": "array", + "maxItems": 3, + "items": { + "enum": [ + "compose", + "commandBox", + "message" + ] + }, + "description": "Context where the command would apply", + "default": [ + "compose", + "commandBox" + ] + }, + "title": { + "type": "string", + "description": "Title of the command.", + "maxLength": 32 + }, + "description": { + "type": "string", + "description": "Description of the command.", + "maxLength": 128 + }, + "initialRun": { + "type": "boolean", + "description": "A boolean value that indicates if the command should be run once initially with no parameter.", + "default": false + }, + "fetchTask": { + "type": "boolean", + "description": "A boolean value that indicates if it should fetch task module dynamically", + "default": false + }, + "semanticDescription": { + "type": "string", + "description": "Semantic description for the command.", + "maxLength": 5000 + }, + "parameters": { + "type": "array", + "maxItems": 5, + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Name of the parameter.", + "maxLength": 64 + }, + "inputType": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "date", + "time", + "toggle", + "choiceset" + ], + "description": "Type of the parameter", + "default": "text" + }, + "title": { + "type": "string", + "description": "Title of the parameter.", + "maxLength": 32 + }, + "description": { + "type": "string", + "description": "Description of the parameter.", + "maxLength": 128 + }, + "value": { + "type": "string", + "description": "Initial value for the parameter", + "maxLength": 512 + }, + "isRequired": { + "type": "boolean", + "description": "The value indicates if this parameter is a required field.", + "default": false + }, + "semanticDescription": { + "type": "string", + "description": "Semantic description for the parameter.", + "maxLength": 2000 + }, + "choices": { + "type": "array", + "maxItems": 10, + "description": "The choice options for the parameter", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title of the choice", + "maxLength": 128 + }, + "value": { + "type": "string", + "description": "Value of the choice", + "maxLength": 512 + } + }, + "additionalProperties": false, + "required": [ + "title", + "value" + ] + } + } + }, + "required": [ + "name", + "title" + ] + } + }, + "taskInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": { + "type": "string", + "description": "Initial dialog title", + "maxLength": 64 + }, + "width": { + "$ref": "#/definitions/taskInfoDimension", + "description": "Dialog width - either a number in pixels or default layout such as 'large', 'medium', or 'small'" + }, + "height": { + "$ref": "#/definitions/taskInfoDimension", + "description": "Dialog height - either a number in pixels or default layout such as 'large', 'medium', or 'small'" + }, + "url": { + "$ref": "#/definitions/httpsUrl", + "description": "Initial webview URL" + } + } + } + }, + "required": [ + "id", + "title" + ] + } + }, + "messageHandlers": { + "type": "array", + "maxItems": 5, + "description": "A list of handlers that allow apps to be invoked when certain conditions are met", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ], + "description": "Type of the message handler" + }, + "value": { + "type": "object", + "properties": { + "domains": { + "type": "array", + "description": "A list of domains that the link message handler can register for, and when they are matched the app will be invoked", + "items": { + "type": "string", + "maxLength": 2048 + } + }, + "supportsAnonymizedPayloads": { + "type": "boolean", + "description": "A boolean that indicates whether the app's link message handler supports anonymous invoke flow.", + "default": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "type", + "value" + ], + "additionalProperties": false + } + }, + "requirementSet": { + "$ref": "#/definitions/elementRequirementSet" + } + } + } + }, + "permissions": { + "type": "array", + "description": "Specifies the permissions the app requests from users.", + "maxItems": 2, + "items": { + "enum": [ + "identity", + "messageTeamMembers" + ] + } + }, + "devicePermissions": { + "type": "array", + "description": "Specify the native features on a user's device that your app may request access to.", + "maxItems": 5, + "items": { + "enum": [ + "geolocation", + "media", + "notifications", + "midi", + "openExternal" + ] + } + }, + "validDomains": { + "type": "array", + "description": "A list of valid domains from which the tabs expect to load any content. Domain listings can include wildcards, for example `*.example.com`. If your tab configuration or content UI needs to navigate to any other domain besides the one use for tab configuration, that domain must be specified here.", + "maxItems": 16, + "items": { + "type": "string", + "maxLength": 2048 + } + }, + "webApplicationInfo": { + "type": "object", + "description": "Specify your AAD App ID and Graph information to help users seamlessly sign into your AAD app.", + "properties": { + "id": { + "$ref": "#/definitions/guid", + "description": "AAD application id of the app. This id must be a GUID." + }, + "resource": { + "type": "string", + "description": "Resource url of app for acquiring auth token for SSO.", + "maxLength": 2048 + }, + "nestedAppAuthInfo": { + "type": "array", + "maxItems": 5, + "description": "By including this property, an NAA token based on its contents will be prefetched when the tab is loaded.", + "items": { + "type": "object", + "properties": { + "redirectUri": { + "type": "string", + "description": "Represents the nested app's valid redirect URI (always a base origin)." + }, + "scopes": { + "type": "array", + "description": "Represents the stringified list of scopes the access token requested requires. Order must match that of the proceeding NAA request in the app.", + "maxItems": 20, + "items": { + "type": "string" + } + }, + "claims": { + "type": "string", + "description": "An optional JSON formatted object of client capabilities that represents if the resource server is CAE capable. Do not use an empty string for this value. If unsupported, keep the field undefined. If supported, use the following string exactly: '{\"access_token\":{\"xms_cc\":{\"values\":[\"CP1\"]}}}'. More info on client capabilities here: https://learn.microsoft.com/en-us/entra/identity-platform/claims-challenge?tabs=dotnet#how-to-communicate-client-capabilities-to-microsoft-entra-id ", + "minLength": 1 + } + }, + "required": [ "redirectUri", "scopes" ], + "additionalProperties": false + } + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "graphConnector": { + "type": "object", + "description": "Specify the app's Graph connector configuration. If this is present then webApplicationInfo.id must also be specified.", + "properties": { + "notificationUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url where Graph-connector notifications for the application should be sent." + } + }, + "required": [ + "notificationUrl" + ], + "additionalProperties": false + }, + "showLoadingIndicator": { + "type": "boolean", + "description": "A value indicating whether or not show loading indicator when app/tab is loading", + "default": false + }, + "isFullScreen": { + "type": "boolean", + "description": "A value indicating whether a personal app is rendered without a tab header-bar", + "default": false + }, + "activities": { + "type": "object", + "properties": { + "activityTypes": { + "type": "array", + "description": "Specify the types of activites that your app can post to a users activity feed", + "maxItems": 128, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "maxLength": 64 + }, + "description": { + "type": "string", + "maxLength": 128 + }, + "templateText": { + "type": "string", + "maxLength": 128 + }, + "allowedIconIds": { + "type": "array", + "description": "An array containing valid icon IDs per activity type.", + "maxItems": 50, + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "description", + "templateText" + ], + "additionalProperties": false + } + }, + "activityIcons": { + "type": "array", + "description": "Specify the customized icons that your app can post to a users activity feed", + "maxItems": 50, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 64, + "description": "Represents the unique icon ID." + }, + "iconFile": { + "type": "string", + "maxLength": 128, + "description": "Represents the relative path to the icon image. Image should be size 32x32." + } + }, + "required": [ + "id", + "iconFile" + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "configurableProperties": { + "type": "array", + "description": "A list of tenant configured properties for an app", + "maxItems": 9, + "items": { + "enum": [ + "name", + "shortDescription", + "longDescription", + "smallImageUrl", + "largeImageUrl", + "accentColor", + "developerUrl", + "privacyUrl", + "termsOfUseUrl" + ] + } + }, + "supportedChannelTypes": { + "type": "array", + "description": "List of 'non-standard' channel types that the app supports. Note: Channels of standard type are supported by default if the app supports team scope.", + "maxItems": 2, + "items": { + "enum": [ + "sharedChannels", + "privateChannels" + ] + } + }, + "supportsChannelFeatures": { + "type": "string", + "enum": [ + "tier1", + null + ], + "description": "A property in the app manifest that declares support for all channel features, categorized by tiers." + }, + "defaultBlockUntilAdminAction": { + "type": "boolean", + "description": "A value indicating whether an app is blocked by default until admin allows it", + "default": false + }, + "publisherDocsUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url to the page that provides additional app information for the admins" + }, + "defaultInstallScope": { + "type": "string", + "enum": [ + "personal", + "team", + "groupChat", + "meetings", + "copilot" + ], + "description": "The install scope defined for this app by default. This will be the option displayed on the button when a user tries to add the app" + }, + "defaultGroupCapability": { + "type": "object", + "properties": { + "team": { + "type": "string", + "enum": [ + "tab", + "bot", + "connector" + ], + "description": "When the install scope selected is Team, this field specifies the default capability available" + }, + "groupchat": { + "type": "string", + "enum": [ + "tab", + "bot", + "connector" + ], + "description": "When the install scope selected is GroupChat, this field specifies the default capability available" + }, + "meetings": { + "type": "string", + "enum": [ + "tab", + "bot", + "connector" + ], + "description": "When the install scope selected is Meetings, this field specifies the default capability available" + } + }, + "description": "When a group install scope is selected, this will define the default capability when the user installs the app", + "additionalProperties": false + }, + "meetingExtensionDefinition": { + "type": "object", + "properties": { + "scenes": { + "description": "Meeting supported scenes.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/definitions/guid", + "description": "A unique identifier for this scene. This id must be a GUID." + }, + "name": { + "type": "string", + "description": "Scene name.", + "maxLength": 128 + }, + "file": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a scene metadata json file." + }, + "preview": { + "$ref": "#/definitions/relativePath", + "description": "A relative file path to a scene PNG preview icon." + }, + "maxAudience": { + "type": "integer", + "description": "Maximum audiences supported in scene.", + "maximum": 50 + }, + "seatsReservedForOrganizersOrPresenters": { + "type": "integer", + "description": "Number of seats reserved for organizers or presenters.", + "maximum": 50 + } + }, + "required": [ + "id", + "name", + "file", + "preview", + "maxAudience", + "seatsReservedForOrganizersOrPresenters" + ] + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "supportsCustomShareToStage": { + "description": "Represents if the app has added support for sharing to stage.", + "type": "boolean", + "default": false + }, + "supportsStreaming": { + "type": "boolean", + "description": "A boolean value indicating whether this app can stream the meeting's audio video content to an RTMP endpoint.", + "default": false + }, + "supportsAnonymousGuestUsers": { + "type": "boolean", + "description": "A boolean value indicating whether this app allows management by anonymous users.", + "default": false + } + }, + "description": "Specify meeting extension definition.", + "additionalProperties": false + }, + "authorization": { + "type": "object", + "description": "Specify and consolidates authorization related information for the App.", + "additionalProperties": false, + "properties": { + "permissions": { + "type": "object", + "description": "List of permissions that the app needs to function.", + "additionalProperties": false, + "properties": { + "resourceSpecific": { + "description": "Permissions that must be granted on a per resource instance basis.", + "maxItems": 16, + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the resource-specific permission.", + "maxLength": 128 + }, + "type": { + "type": "string", + "enum": [ + "Application", + "Delegated" + ], + "description": "The type of the resource-specific permission: delegated vs application." + } + }, + "required": [ + "name", + "type" + ] + } + } + } + } + } + }, + "extensions": { + "$ref": "#/definitions/elementExtensions" + }, + "dashboardCards": { + "type": "array", + "description": "Defines the list of cards which could be pinned to dashboards that can provide summarized view of information relevant to user.", + "items": { + "$ref": "#/definitions/dashboardCard" + }, + "additionalProperties": false + }, + "copilotAgents": { + "type": "object", + "properties": { + "declarativeAgents": { + "type": "array", + "description": "An array of declarative agent elements references. Currently, only one declarative agent per application is supported.", + "items": { + "$ref": "#/definitions/declarativeAgentRef" + }, + "minItems": 1, + "maxItems": 1 + }, + "customEngineAgents": { + "type": "array", + "description": "An array of Custom Engine Agents. Currently only one Custom Engine Agent per application is supported. Support is currently in public preview.", + "items": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/guid", + "description": "The id of the Custom Engine Agent. If it is of type bot, the id must match the id specified in a bot in the bots node and the referenced bot must have personal scope. The app short name and short description must also be defined." + }, + "type": { + "type": "string", + "enum": [ + "bot" + ], + "description": "The type of the Custom Engine Agent. Currently only type bot is supported." + }, + "disclaimer": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The message shown to users before they interact with this application. ", + "maxLength": 500 + } + }, + "required": [ + "text" + ] + } + }, + "required": [ + "id", + "type" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 1 + } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "declarativeAgents" + ] + }, + { + "required": [ + "customEngineAgents" + ] + } + ] + }, + "intuneInfo": { + "type": "object", + "description": "The Intune-related properties for the app.", + "properties": { + "supportedMobileAppManagementVersion": { + "type": "string", + "description": "Supported mobile app managment version that the app is compliant with.", + "maxLength": 64 + } + }, + "additionalProperties": false + }, + "agenticUserTemplates": { + "type": "array", + "description": "An array of agentic user templates references.", + "items": { + "$ref": "#/definitions/agenticUserTemplateRef" + }, + "minimum": 1, + "maxItems": 1 + }, + "elementRelationshipSet": { + "type": "object", + "properties": { + "oneWayDependencies": { + "type": "array", + "items": { + "$ref": "#/definitions/oneWayDependency" + }, + "minItems": 1, + "description": "An array containing multiple instances of unidirectional dependency relationships (each represented by a oneWayDependency object)." + }, + "mutualDependencies": { + "type": "array", + "items": { + "$ref": "#/definitions/mutualDependency" + }, + "minItems": 1, + "description": "An array containing multiple instances of mutual dependency relationships between elements (each represented by a mutualDependency object)." + } + }, + "anyOf": [ + { + "required": [ + "oneWayDependencies" + ] + }, + { + "required": [ + "mutualDependencies" + ] + } + ], + "additionalProperties": false + }, + "backgroundLoadConfiguration": { + "type": "object", + "description": "Optional property containing background loading configuration. By opting in to this performance enhancement, your app is eligible to be loaded in the background in any Microsoft 365 application host that supports this feature.", + "properties": { + "tabConfiguration": { + "type": "object", + "description": "Optional property within backgroundLoadConfiguration containing tab settings for background loading.", + "properties": { + "contentUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "Required URL for background loading. This can be the same contentUrl from the staticTabs section or an alternative endpoint used for background loading." + } + }, + "required": [ "contentUrl" ], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": [ + "manifestVersion", + "version", + "id", + "developer", + "name", + "description", + "icons", + "accentColor" + ], + "definitions": { + "relativePath": { + "type": "string", + "maxLength": 2048 + }, + "httpsUrl": { + "type": "string", + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://" + }, + "anyHttpUrl": { + "type": "string", + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]?://" + }, + "secureHttpUrl": { + "type": "string", + "maxLength": 2048, + "pattern": "^[Hh][Tt][Tt][Pp][Ss]://" + }, + "semver": { + "type": "string", + "maxLength": 256, + "pattern": "^([0-9]|[1-9]+[0-9]*)\\.([0-9]|[1-9]+[0-9]*)\\.([0-9]|[1-9]+[0-9]*)$" + }, + "hexColor": { + "type": "string", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "guid": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$" + }, + "languageTag": { + "type": "string", + "pattern": "^[A-Za-z0-9]{1,8}(-[A-Za-z0-9]{1,8}){0,2}$" + }, + "taskInfoDimension": { + "type": "string", + "pattern": "^((([0-9]*\\.)?[0-9]+)|[lL][aA][rR][gG][eE]|[mM][eE][dD][iI][uU][mM]|[sS][mM][aA][lL][lL])$", + "maxLength": 16 + }, + "elementReference": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "bots", + "staticTabs", + "composeExtensions", + "configurableTabs" + ] + }, + "id": { + "type": "string" + }, + "commandIds": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "id" + ], + "additionalProperties": false + }, + "oneWayDependency": { + "type": "object", + "properties": { + "element": { + "$ref": "#/definitions/elementReference" + }, + "dependsOn": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/elementReference" + } + } + }, + "required": [ + "element", + "dependsOn" + ], + "additionalProperties": false, + "description": "An object representing a unidirectional dependency relationship, where one specific element (referred to as the `element`) relies on an array of other elements (referred to as the `dependsOn`) in a single direction." + }, + "mutualDependency": { + "type": "array", + "minItems": 2, + "items": { + "$ref": "#/definitions/elementReference" + }, + "description": "A specific instance of mutual dependency between two or more elements, indicating that each element depends on the others in a bidirectional manner." + }, + "elementRequirementSet": { + "type": "object", + "properties": { + "hostMustSupportFunctionalities": { + "type": "array", + "items": { + "$ref": "#/definitions/hostFunctionality" + }, + "minItems": 1 + } + }, + "required": [ + "hostMustSupportFunctionalities" + ], + "additionalProperties": false, + "description": "An object representing a set of requirements that the host must support for the element." + }, + "hostFunctionality": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "dialogUrl", + "dialogUrlBot", + "dialogAdaptiveCard", + "dialogAdaptiveCardBot" + ], + "description": "The name of the functionality." + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "description": "An object representing a specific functionality that a host must support." + }, + "elementExtensions": { + "type": "array", + "description": "The set of extensions for this app. Currently only one extensions per app is supported.", + "maxItems": 1, + "items": { + "type": "object", + "minProperties": 1, + "properties": { + "requirements": { + "$ref": "#/definitions/requirementsExtensionElement" + }, + "runtimes": { + "$ref": "#/definitions/extensionRuntimesArray" + }, + "ribbons": { + "$ref": "#/definitions/extensionRibbonsArray" + }, + "autoRunEvents": { + "$ref": "#/definitions/extensionAutoRunEventsArray" + }, + "alternates": { + "$ref": "#/definitions/extensionAlternateVersionsArray" + }, + "contentRuntimes": { + "$ref": "#/definitions/extensionContentRuntimeArray" + }, + "getStartedMessages": { + "$ref": "#/definitions/extensionGetStartedMessageArray" + }, + "contextMenus": { + "$ref": "#/definitions/extensionContextMenuArray" + }, + "keyboardShortcuts": { + "type": "array", + "items": { + "$ref": "#/definitions/extensionKeyboardShortcut" + }, + "minItems": 1, + "maxItems": 10 + }, + "audienceClaimUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The url for your extension, used to validate Exchange user identity tokens." + } + }, + "additionalProperties": false + }, + "additionalProperties": false + }, + "requirementsExtensionElement": { + "type": "object", + "description": "Specifies limitations on which clients the add-in can be installed on, including limitations on the Office host application, the form factors, and the requirement sets that the client must support.", + "minProperties": 1, + "properties": { + "capabilities": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Identifies the name of the requirement sets that the add-in needs to run.", + "maxLength": 128 + }, + "minVersion": { + "type": "string", + "description": "Identifies the minimum version for the requirement sets that the add-in needs to run." + }, + "maxVersion": { + "type": "string", + "description": "Identifies the maximum version for the requirement sets that the add-in needs to run." + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + } + }, + "scopes": { + "type": "array", + "description": "Identifies the scopes in which the add-in can run. Supported values: 'mail', 'workbook', 'document', 'presentation'.", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "string", + "enum": [ + "mail", + "workbook", + "document", + "presentation" + ] + } + }, + "formFactors": { + "type": "array", + "description": "Identifies the form factors that support the add-in. Supported values: mobile, desktop.", + "minItems": 1, + "maxItems": 2, + "items": { + "type": "string", + "enum": [ + "desktop", + "mobile" + ] + } + } + }, + "additionalProperties": false + }, + "extensionRuntimesArray": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "type": "object", + "description": "A runtime environment for a page or script", + "properties": { + "requirements": { + "$ref": "#/definitions/requirementsExtensionElement" + }, + "id": { + "type": "string", + "description": "A unique identifier for this runtime within the app. Maximum length is 64 characters.", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "general" + ], + "default": "general", + "description": "Supports running functions and launching pages." + }, + "code": { + "$ref": "#/definitions/extensionRuntimeCode" + }, + "lifetime": { + "type": "string", + "default": "short", + "enum": [ + "short", + "long" + ], + "description": "Runtimes with a short lifetime do not preserve state across executions. Runtimes with a long lifetime do." + }, + "actions": { + "$ref": "#/definitions/extensionRuntimesActions" + }, + "customFunctions": { + "$ref": "#/definitions/extensionCustomFunctions" + } + }, + "additionalProperties": false, + "required": [ + "id", + "code" + ] + } + }, + "extensionCustomFunctions": { + "type": "object", + "description": "Custom function enable developers to add new functions to Excel by defining those functions in JavaScript as part of an add-in. Users within Excel can access custom functions just as they would any native function in Excel, such as SUM().", + "properties": { + "functions": { + "description": "Array of function object which defines function metadata.", + "items": { + "$ref": "#/definitions/extensionFunction" + }, + "maxItems": 20000, + "minItems": 1, + "type": "array" + }, + "namespace": { + "$ref": "#/definitions/extensionCustomFunctionsNamespace" + }, + "allowCustomDataForDataTypeAny": { + "type": "boolean", + "description": "Allows a custom function to accept Excel data types as parameters and return values.", + "default": false + }, + "metadataUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The full URL of a metadata json file with default locale." + }, + "enums": { + "type": "array", + "description": "Array of custom defined enum objects.", + "items": { + "$ref": "#/definitions/enum" + }, + "maxItems": 20000 + } + }, + "additionalProperties": false + }, + "enum": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique ID for the enum.", + "maxLength": 64, + "minLength": 3, + "pattern": "^[A-Za-z][A-Za-z0-9._]*$" + }, + "type": { + "type": "string", + "description": "The type of the values in this enum.", + "enum": [ "number", "string" ] + }, + "values": { + "type": "array", + "description": "Array that defines the constants for the enum.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "A brief description of the constant.", + "maxLength": 256 + }, + "numberValue": { + "type": ["number", "null"], + "description": "When enum type is number, the actual number value of the constant." + }, + "stringValue": { + "type": "string", + "description": "When enum type is string, the actual string value of the constant." + }, + "tooltip": { + "type": "string", + "description": "Additional information about the constant, intended to provide more context or details.", + "maxLength": 256 + } + }, + "additionalProperties": false, + "required": [ "name" ] + } + } + }, + "required": [ + "id", + "type", + "values" + ], + "additionalProperties": false + }, + "extensionCustomFunctionsNamespace": { + "type": "object", + "description": "Defines the namespace for your custom functions. A namespace prepends itself to your custom functions to help customers identify your functions as part of your add-in.", + "properties": { + "id": { + "type": "string", + "description": "Non-localizable version of the namespace.", + "pattern": "^[A-Za-z][A-Za-z0-9._]*$", + "minLength": 1, + "maxLength": 32 + }, + "name": { + "type": "string", + "description": "Localizable version of the namespace.", + "pattern": "^[A-Za-z][A-Za-z0-9._]*$", + "minLength": 1, + "maxLength": 32 + } + }, + "required": [ + "id", + "name" + ] + }, + "extensionFunction": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique ID for the function.", + "pattern": "^[a-zA-Z][a-zA-Z0-9._]*$", + "minLength": 3, + "maxLength": 64 + }, + "name": { + "type": "string", + "description": "The name of the function that end users see in Excel. In Excel, this function name is prefixed by the custom functions namespace that's specified in the manifest file.", + "pattern": "^[\\p{L}][\\p{L}0-9._]*$", + "minLength": 3, + "maxLength": 64 + }, + "description": { + "type": "string", + "description": "The description of the function that end users see in Excel.", + "minLength": 1, + "maxLength": 128 + }, + "helpUrl": { + "type": "string", + "description": "URL that provides information about the function. (It is displayed in a task pane.)", + "format": "uri", + "minLength": 1, + "maxLength": 2048 + }, + "parameters": { + "type": "array", + "description": "Array that defines the input parameters for the function.", + "items": { + "$ref": "#/definitions/extensionFunctionParameter" + }, + "minItems": 0, + "maxItems": 128 + }, + "result": { + "$ref": "#/definitions/extensionResult" + }, + "stream": { + "type": "boolean", + "description": "If true, the function can output repeatedly to the cell even when invoked only once. This option is useful for rapidly-changing data sources, such as a stock price. The function should have no return statement. Instead, the result value is passed as the argument of the StreamingInvocation.setResult callback function.", + "default": false + }, + "volatile": { + "type": "boolean", + "description": "If true, the function recalculates each time Excel recalculates, instead of only when the formula's dependent values have changed. A function can't use both the stream and volatile properties. If the stream and volatile properties are both set to true, the volatile property will be ignored.", + "default": false + }, + "cancelable": { + "type": "boolean", + "description": "If true, Excel calls the CancelableInvocation handler whenever the user takes an action that has the effect of canceling the function; for example, manually triggering recalculation or editing a cell that is referenced by the function. Cancelable functions are typically only used for asynchronous functions that return a single result and need to handle the cancellation of a request for data. A function can't use both the stream and cancelable properties.", + "default": false + }, + "requiresAddress": { + "type": "boolean", + "description": "If true, your custom function can access the address of the cell that invoked it. The address property of the invocation parameter contains the address of the cell that invoked your custom function. A function can't use both the stream and requiresAddress properties.", + "default": false + }, + "requiresParameterAddress": { + "type": "boolean", + "description": "If true, your custom function can access the addresses of the function's input parameters. This property must be used in combination with the dimensionality property of the result object, and dimensionality must be set to matrix.", + "default": false + }, + "requiresStreamAddress": { + "type": "boolean", + "default": false, + "description": "If `true`, the function can access the address of the cell calling the streaming function. The `address` property of the invocation parameter contains the address of the cell that invoked your streaming function. " + }, + "requiresStreamParameterAddresses": { + "type": "boolean", + "description": "If `true`, the function can access the parameter addresses of the cell calling the streaming function. The `parameterAddresses` property of the invocation parameter contains the parameter addresses for your streaming function.", + "default": false + }, + "capturesCallingObject": { + "type": "boolean", + "description": "If `true`, the data type being referenced by the custom function is passed as the first argument to the custom function.", + "default": false + }, + "excludeFromAutoComplete": { + "type": "boolean", + "description": "If `true`, the custom function will not appear in the formula AutoComplete menu in Excel.", + "default": false + }, + "linkedEntityLoadService": { + "type": "boolean", + "description": "If `true`, it designates that the function is a linked entity load service that returns linked entity cell values for linked entity IDs requested by Excel.", + "default": false + } + }, + "required": [ + "id", + "name", + "parameters", + "result" + ] + }, + "extensionFunctionParameter": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the parameter. This name is displayed in Excel's IntelliSense.", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "type": "string", + "description": "A description of the parameter. This is displayed in Excel's IntelliSense.", + "minLength": 1, + "maxLength": 128 + }, + "type": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "The data type of the parameter. It can only be 'boolean', 'number', 'string', 'any', 'CustomFunctions.Invocation', 'CustomFunctions.StreamingInvocation' or 'CustomFunctions.CancelableInvocation', 'any' allows you to use any of other types.", + "default": "any" + }, + "cellValueType": { + "type": "string", + "enum": [ + "cellvalue", + "booleancellvalue", + "doublecellvalue", + "entitycellvalue", + "errorcellvalue", + "linkedentitycellvalue", + "localimagecellvalue", + "stringcellvalue", + "webimagecellvalue", + null + ], + "description": "A subfield of the type property. Specifies the Excel data types accepted by the custom function. Accepts the values cellvalue, booleancellvalue, doublecellvalue, entitycellvalue, errorcellvalue, linkedentitycellvalue, localimagecellvalue, stringcellvalue, webimagecellvalue" + }, + "dimensionality": { + "type": "string", + "enum": [ + "scalar", + "matrix" + ], + "default": "scalar", + "description": "Must be either scalar (a non-array value) or matrix (a 2-dimensional array)." + }, + "optional": { + "type": [ "boolean", "null" ], + "description": "If true, the parameter is optional." + }, + "repeating": { + "type": "boolean", + "default": false, + "description": "If true, parameters populate from a specified array. Note that functions all repeating parameters are considered optional parameters by definition." + }, + "customEnumId": { + "type": "string", + "description": "|The `id` of the enum in the `enums` array. This associates the custom enum with the function and enables Excel to display the enum members in the formula AutoComplete menu.", + "maxLength": 64 + } + }, + "required": [ "name" ] + }, + "extensionResult": { + "type": "object", + "description": "Object that defines the type of information that is returned by the function.", + "properties": { + "dimensionality": { + "type": "string", + "enum": [ + "scalar", + "matrix" + ], + "default": "scalar", + "description": "Must be either scalar (a non-array value) or matrix (a 2-dimensional array). Default: scalar." + } + } + }, + "extensionRuntimesActions": { + "type": "array", + "description": "Specifies the set of actions supported by this runtime. An action is either running a JavaScript function or opening a view such as a task pane.", + "minItems": 1, + "maxItems": 150, + "items": { + "$ref": "#/definitions/extensionRuntimesActionsItem" + }, + "additionalProperties": false + }, + "extensionRuntimesActionsItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier for this action. Maximum length is 64 characters. This value is passed to the code file.", + "maxLength": 64 + }, + "type": { + "type": "string", + "enum": [ + "executeFunction", + "openPage", + "executeDataFunction" + ], + "description": "executeFunction: Run a script function without waiting for it to finish. openPage: Open a page in a view. executeDataFunction: invoke command and retrieve data." + }, + "displayName": { + "type": "string", + "description": "Display name of the action. Maximum length is 64 characters.", + "maxLength": 64 + }, + "pinnable": { + "type": "boolean", + "description": "Specifies that a task pane supports pinning, which keeps the task pane open when the user changes the selection." + }, + "view": { + "type": "string", + "description": "View where the page should be opened. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "multiselect": { + "type": "boolean", + "description": "Whether allows the action to have multiple selection.", + "default": false + }, + "supportsNoItemContext": { + "type": "boolean", + "description": "Whether allows task pane add-ins to activate without the Reading Pane enabled or a message selected. ", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "id", + "type" + ] + }, + "extensionRibbonsArray": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "requirements": { + "$ref": "#/definitions/requirementsExtensionElement" + }, + "contexts": { + "$ref": "#/definitions/extensionContexts" + }, + "tabs": { + "type": "array", + "maxItems": 20, + "items": { + "$ref": "#/definitions/extensionRibbonsArrayTabsItem" + } + }, + "fixedControls": { + "type": "array", + "items": { + "$ref": "#/definitions/extensionRibbonsArrayFixedControlItem" + }, + "minItems": 1, + "maxItems": 1 + }, + "spamPreProcessingDialog": { + "$ref": "#/definitions/extensionRibbonsSpamPreProcessingDialog" + } + }, + "additionalProperties": false, + "required": [ + "tabs" + ] + } + }, + "extensionContexts": { + "type": "array", + "description": "Specifies the Office application windows in which the ribbon customization is available to the user. Each item in the array is a member of a string array. Possible values are: mailRead, mailCompose, meetingDetailsOrganizer, meetingDetailsAttendee, onlineMeetingDetailsOrganizer, logEventMeetingDetailsAttendee, spamReportingOverride.", + "minItems": 1, + "maxItems": 7, + "items": { + "type": "string", + "enum": [ + "mailRead", + "mailCompose", + "meetingDetailsOrganizer", + "meetingDetailsAttendee", + "onlineMeetingDetailsOrganizer", + "logEventMeetingDetailsAttendee", + "default", + "spamReportingOverride" + ] + } + }, + "extensionRibbonsArrayTabsItem": { + "type": "object", + "minProperties": 1, + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this tab within the app. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Displayed text for the tab. Maximum length is 64 characters.", + "maxLength": 64 + }, + "position": { + "type": "object", + "properties": { + "builtInTabId": { + "type": "string", + "description": "The id of the built-in tab. Maximum length is 64 characters.", + "maxLength": 64 + }, + "align": { + "type": "string", + "description": "Define alignment of this custom tab relative to the specified built-in tab.", + "enum": [ + "after", + "before" + ] + } + }, + "additionalProperties": false, + "required": [ + "builtInTabId", + "align" + ] + }, + "builtInTabId": { + "type": "string", + "description": "Id of the existing office Tab. Maximum length is 64 characters.", + "maxLength": 64 + }, + "groups": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "description": "Defines tab groups.", + "items": { + "$ref": "#/definitions/extensionRibbonsCustomTabGroupsItem" + } + }, + "customMobileRibbonGroups": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "description": "Defines mobile group item.", + "items": { + "$ref": "#/definitions/extensionRibbonsCustomMobileGroupItem" + } + } + }, + "dependencies": { + "builtInTabId": { + "properties": { + "groups": { + "type": "array", + "maxItems": 10, + "items": { + "$ref": "#/definitions/extensionCommonCustomGroup" + } + } + }, + "required": [ + "builtInTabId" + ] + }, + "id": { + "anyOf": [ + { + "required": [ + "id", + "label", + "groups" + ] + }, + { + "required": [ + "id", + "label", + "customMobileRibbonGroups" + ] + } + ] + } + }, + "additionalProperties": false + }, + "extensionRibbonsCustomTabGroupsItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this group within the app. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Displayed text for the group. Maximum length is 64 characters.", + "maxLength": 64 + }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "$ref": "#/definitions/extensionCommonIcon" + } + }, + "controls": { + "type": "array", + "items": { + "$ref": "#/definitions/extensionCommonCustomGroupControlsItem" + }, + "minItems": 1, + "maxItems": 20 + }, + "builtInGroupId": { + "type": "string", + "description": "Id of a built-in Group. Maximum length is 64 characters.", + "maxLength": 64 + }, + "overriddenByRibbonApi": { + "type": "boolean", + "description": "Specifies whether a group will be hidden on application and platform combinations that support the API (Office.ribbon.requestCreateControls) that installs custom contextual tabs on the ribbon. Default is false.", + "default": "false" + } + }, + "additionalProperties": false + }, + "extensionCommonCustomGroup": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this group within the app. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Displayed text for the group. Maximum length is 64 characters.", + "maxLength": 64 + }, + "icons": { + "type": "array", + "description": "Displayed icons for the group.", + "minItems": 1, + "maxItems": 3, + "items": { + "$ref": "#/definitions/extensionCommonIcon" + } + }, + "controls": { + "type": "array", + "description": "Configures the buttons and menus in the group.", + "items": { + "$ref": "#/definitions/extensionCommonCustomGroupControlsItem" + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "id", + "label", + "controls" + ], + "additionalProperties": false + }, + "extensionCommonCustomGroupControlsItem": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this control within the app. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "type": { + "type": "string", + "description": "Defines the type of control whether button or menu.", + "enum": [ + "button", + "menu" + ] + }, + "builtInControlId": { + "type": "string", + "description": "Id of an existing office control. Maximum length is 64 characters.", + "maxLength": 64 + }, + "label": { + "type": "string", + "description": "Displayed text for the control. Maximum length is 64 characters.", + "maxLength": 64 + }, + "icons": { + "type": "array", + "description": "Configures the icons for the custom control.", + "minItems": 1, + "maxItems": 3, + "items": { + "$ref": "#/definitions/extensionCommonIcon" + } + }, + "supertip": { + "$ref": "#/definitions/extensionCommonSuperToolTip" + }, + "actionId": { + "type": "string", + "description": "The ID of an execution-type action that handles this key combination. Maximum length is 64 characters.", + "maxLength": 64 + }, + "overriddenByRibbonApi": { + "type": "boolean", + "description": "Specifies whether a group, button, menu, or menu item will be hidden on application and platform combinations that support the API (Office.ribbon.requestCreateControls) that installs custom contextual tabs on the ribbon. Default is false.", + "default": "false" + }, + "enabled": { + "type": "boolean", + "description": "Whether the control is initially enabled.", + "default": true + }, + "items": { + "type": "array", + "description": "Configures the items for a menu control.", + "minItems": 1, + "maxItems": 30, + "items": { + "$ref": "#/definitions/extensionCommonCustomControlMenuItem" + } + } + }, + "required": [ + "id", + "type", + "label", + "icons", + "supertip" + ] + }, + "extensionRibbonsCustomMobileGroupItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Specify the Id of the group. Used for mobileMessageRead ext point.", + "maxLength": 250 + }, + "label": { + "type": "string", + "description": "Short label of the control. Maximum length is 32 characters.", + "maxLength": 32 + }, + "controls": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "items": { + "$ref": "#/definitions/extensionRibbonsCustomMobileControlButtonItem" + } + } + }, + "required": [ + "id", + "label", + "controls" + ] + }, + "extensionCommonCustomControlMenuItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this control within the app. Maximum length is 64 characters. ", + "maxLength": 64 + }, + "type": { + "type": "string", + "description": "Supported values: menuItem.", + "enum": [ + "menuItem" + ] + }, + "label": { + "type": "string", + "description": "Displayed text for the control. Maximum length is 64 characters.", + "maxLength": 64 + }, + "icons": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "items": { + "$ref": "#/definitions/extensionCommonIcon" + } + }, + "supertip": { + "$ref": "#/definitions/extensionCommonSuperToolTip" + }, + "actionId": { + "type": "string", + "description": "The ID of an action defined in runtimes. Maximum length is 64 characters.", + "maxLength": 64 + }, + "enabled": { + "type": "boolean", + "description": "Whether the control is initially enabled.", + "default": true + }, + "overriddenByRibbonApi": { + "type": "boolean", + "default": "false" + } + }, + "additionalProperties": false, + "required": [ + "id", + "type", + "label", + "supertip", + "actionId" + ] + }, + "extensionRibbonsCustomMobileControlButtonItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Specify the Id of the button like msgReadFunctionButton.", + "maxLength": 250 + }, + "type": { + "type": "string", + "enum": [ + "mobileButton" + ] + }, + "label": { + "type": "string", + "description": "Short label of the control. Maximum length is 32 characters.", + "maxLength": 32 + }, + "icons": { + "type": "array", + "items": { + "$ref": "#/definitions/extensionCustomMobileIcon" + }, + "minItems": 9, + "maxItems": 9 + }, + "actionId": { + "type": "string", + "description": "The ID of an action defined in runtimes. Maximum length is 64 characters.", + "maxLength": 64 + } + }, + "required": [ + "id", + "type", + "label", + "icons", + "actionId" + ] + }, + "extensionCustomMobileIcon": { + "type": "object", + "properties": { + "size": { + "type": "number", + "description": "Size in pixels of the icon. Three image sizes are required (25, 32, and 48 pixels).", + "enum": [ + 25, + 32, + 48 + ] + }, + "url": { + "$ref": "#/definitions/httpsUrl", + "description": "Url to the icon." + }, + "scale": { + "type": "number", + "description": "How to scale - 1,2,3 for each image. This attribute specifies the UIScreen.scale property for iOS devices.", + "enum": [ + 1, + 2, + 3 + ] + } + }, + "additionalProperties": false, + "required": [ + "size", + "url", + "scale" + ] + }, + "extensionCommonSuperToolTip": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Title text of the super tip. Maximum length is 64 characters.", + "maxLength": 64 + }, + "description": { + "type": "string", + "description": "Description of the super tip. Maximum length is 250 characters.", + "maxLength": 250 + } + }, + "additionalProperties": false, + "required": [ + "title", + "description" + ] + }, + "extensionCommonIcon": { + "type": "object", + "properties": { + "size": { + "type": "number", + "description": "Size in pixels of the icon. Three image sizes are required (16, 32, and 80 pixels)", + "enum": [ + 16, + 20, + 24, + 32, + 40, + 48, + 64, + 80 + ] + }, + "url": { + "$ref": "#/definitions/httpsUrl", + "description": "Absolute Url to the icon." + } + }, + "additionalProperties": false, + "required": [ + "size", + "url" + ] + }, + "extensionAutoRunEventsArray": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "properties": { + "requirements": { + "$ref": "#/definitions/requirementsExtensionElement" + }, + "events": { + "type": "array", + "maxItems": 20, + "description": "Specifies the type of event. For supported types, please see: https://learn.microsoft.com/en-us/office/dev/add-ins/outlook/autolaunch?tabs=xmlmanifest#supported-events.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "maxLength": 64 + }, + "actionId": { + "type": "string", + "description": "The ID of an action defined in runtimes. Maximum length is 64 characters.", + "maxLength": 64 + }, + "options": { + "type": "object", + "description": "Configures how Outlook responds to the event.", + "properties": { + "sendMode": { + "type": "string", + "enum": [ + "promptUser", + "softBlock", + "block" + ] + } + }, + "additionalProperties": false, + "required": [ + "sendMode" + ] + } + }, + "additionalProperties": false, + "required": [ + "type", + "actionId" + ] + } + } + }, + "additionalProperties": false, + "required": [ + "events" + ] + } + }, + "extensionAlternateVersionsArray": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "properties": { + "requirements": { + "$ref": "#/definitions/requirementsExtensionElement" + }, + "prefer": { + "type": "object", + "properties": { + "comAddin": { + "type": "object", + "properties": { + "progId": { + "type": "string", + "description": "Program ID of the alternate com extension. Maximum length is 64 characters.", + "maxLength": 64 + } + }, + "additionalProperties": false, + "required": [ + "progId" + ] + }, + "xllCustomFunctions": { + "$ref": "#/definitions/extensionXllCustomFunctions" + } + }, + "minProperties": 1 + }, + "hide": { + "type": "object", + "properties": { + "storeOfficeAddin": { + "type": "object", + "properties": { + "officeAddinId": { + "type": "string", + "description": "Solution ID of an in-market add-in to hide. Maximum length is 64 characters.", + "maxLength": 64 + }, + "assetId": { + "type": "string", + "description": "Asset ID of the in-market add-in to hide. Maximum length is 64 characters.", + "maxLength": 64 + } + }, + "additionalProperties": false, + "required": [ + "officeAddinId", + "assetId" + ] + }, + "customOfficeAddin": { + "type": "object", + "properties": { + "officeAddinId": { + "type": "string", + "description": "Solution ID of the in-market add-in to hide. Maximum length is 64 characters.", + "maxLength": 64 + } + }, + "additionalProperties": false, + "required": [ + "officeAddinId" + ] + }, + "windowsExtensions": { + "type": "object", + "description": "Configures how to hide windows native extensions", + "properties": { + "effect": { + "type": "string", + "description": "Specifies the effect to take while installing the web add-in if the equivalent add-in is installed.", + "enum": [ + "userOptionToDisable", + "disableWithNotification" + ] + }, + "comAddin": { + "type": "object", + "description": "Specifies the equivalent COM add-ins", + "properties": { + "progIds": { + "type": "array", + "description": "Specifies the program Ids of the equivalent COM add-ins", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "additionalProperties": false, + "required": [ + "progIds" + ] + }, + "automationAddin": { + "type": "object", + "description": "Specifies the equivalent automation add-ins", + "properties": { + "progIds": { + "type": "array", + "description": "Specifies the program Ids of the equivalent automation add-ins", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "additionalProperties": false, + "required": [ + "progIds" + ] + }, + "xllCustomFunctions": { + "type": "object", + "description": "Specifies the XLL-based add-ins custom function", + "properties": { + "fileNames": { + "type": "array", + "description": "Specifies the file names of the XLL-based add-ins custom function", + "minItems": 1, + "maxItems": 5, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + } + }, + "additionalProperties": false, + "required": [ + "fileNames" + ] + } + }, + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "effect", + "comAddin" + ] + }, + { + "required": [ + "effect", + "automationAddin" + ] + }, + { + "required": [ + "effect", + "xllCustomFunctions" + ] + } + ] + } + }, + "minProperties": 1 + }, + "alternateIcons": { + "type": "object", + "additionalProperties": false, + "properties": { + "icon": { + "$ref": "#/definitions/extensionCommonIcon" + }, + "highResolutionIcon": { + "$ref": "#/definitions/extensionCommonIcon" + } + }, + "required": [ + "icon", + "highResolutionIcon" + ] + } + }, + "minProperties": 1, + "additionalProperties": false + } + }, + "extensionXllCustomFunctions": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "File name for the XLL extension. Maximum length is 254 characters.", + "pattern": "^(?!.*[\\r\\n\\f\\b\\v\\u0007\\t])[\\S]*\\.xll$", + "minLength": 4, + "maxLength": 254 + } + } + }, + "extensionKeyboardShortcut": { + "type": "object", + "properties": { + "requirements": { + "description": "Specifies the Office requirement sets.", + "$ref": "#/definitions/requirementsExtensionElement" + }, + "shortcuts": { + "type": "array", + "description": "Array of mappings from actions to the key combinations that invoke the actions.", + "items": { + "$ref": "#/definitions/extensionShortcut" + }, + "minItems": 1, + "maxItems": 20000 + }, + "keyMappingFiles": { + "description": "Specifies the full URLs for shortcuts mapping and localization resource files that don't directly support the unified manifest.", + "$ref": "#/definitions/keyboardShortcutsMappingFiles" + } + } + }, + "keyboardShortcutsMappingFiles": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortcutsUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The full URL of the JSON file that will contain the keyboard combination configuration on Office application and platform combinations that don't directly support the unified manifest." + }, + "localizationResourceUrl": { + "$ref": "#/definitions/httpsUrl", + "description": "The full URL of a file that provides supplemental resource, such as localized strings, for the file specified in the shortcutsUrl attribute." + } + }, + "required": [ "shortcutsUrl" ] + }, + "extensionShortcut": { + "type": "object", + "properties": { + "key": { + "type": "object", + "$ref": "#/definitions/extensionKeyCombination" + }, + "actionId": { + "type": "string", + "description": "The ID of an execution-type action that handles this key combination.", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "key", + "actionId" + ] + }, + "extensionKeyCombination": { + "type": "object", + "description": "Key combinations in different platform (i.e. default, windows, web and mac).", + "properties": { + "default": { + "type": "string", + "description": "Fallback key for any platform that isn't specified.", + "pattern": "^[A-Za-z0-9-_+]+$", + "minLength": 1, + "maxLength": 32 + }, + "mac": { + "type": "string", + "description": "key for mac platform. Alt is mapped to the Option key.", + "pattern": "^[A-Za-z0-9-_+]+$", + "minLength": 1, + "maxLength": 32 + }, + "web": { + "type": "string", + "pattern": "^[A-Za-z0-9-_+]+$", + "description": "key for web platform.", + "minLength": 1, + "maxLength": 32 + }, + "windows": { + "type": "string", + "description": "key for windows platform. Command is mapped to the Ctrl key.", + "pattern": "^[A-Za-z0-9-_+]+$", + "minLength": 1, + "maxLength": 32 + } + }, + "required": [ "default" ] + }, + "extensionContentRuntimeArray": { + "type": "array", + "description": "Content runtime is for 'ContentApp', which can be embedded directly into Excel or PowerPoint documents.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "requirements": { + "type": "object", + "$ref": "#/definitions/requirementsExtensionElement", + "description": "Specifies the Office requirement sets for content add-in runtime. If the user's Office version doesn't support the specified requirements, the component will not be available in that client." + }, + "id": { + "type": "string", + "description": "A unique identifier for this runtime within the app. This is developer specified.", + "maxLength": 64 + }, + "code": { + "$ref": "#/definitions/extensionRuntimeCode", + "description": "Specifies the location of code for this runtime. Depending on the runtime.type, add-ins use either a JavaScript file or an HTML page with an embedded