From 9da6ae6747ec5e6add59e28352a8763dc6d22016 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:49:54 +0800 Subject: [PATCH 01/16] docs(teams): propose delivery and trust decisions --- ...way-capabilities-and-delivery-semantics.md | 241 ++++++++++++++++++ docs/adr/teams-ephemeral-ingress-state.md | 151 +++++++++++ docs/adr/teams-message-reactions-preview.md | 133 ++++++++++ docs/adr/teams-owned-message-mutations.md | 229 +++++++++++++++++ docs/adr/teams-real-send-acknowledgement.md | 205 +++++++++++++++ .../teams-typed-scope-and-mention-routing.md | 191 ++++++++++++++ 6 files changed, 1150 insertions(+) create mode 100644 docs/adr/gateway-capabilities-and-delivery-semantics.md create mode 100644 docs/adr/teams-ephemeral-ingress-state.md create mode 100644 docs/adr/teams-message-reactions-preview.md create mode 100644 docs/adr/teams-owned-message-mutations.md create mode 100644 docs/adr/teams-real-send-acknowledgement.md create mode 100644 docs/adr/teams-typed-scope-and-mention-routing.md diff --git a/docs/adr/gateway-capabilities-and-delivery-semantics.md b/docs/adr/gateway-capabilities-and-delivery-semantics.md new file mode 100644 index 000000000..51760e5b3 --- /dev/null +++ b/docs/adr/gateway-capabilities-and-delivery-semantics.md @@ -0,0 +1,241 @@ +# ADR: Gateway Capabilities and Delivery Semantics + +- **Status:** Proposed +- **Date:** 2026-08-06 +- **Author:** @NeoHsu +- **Related:** + - [Custom Gateway](custom-gateway.md) + - [Unified Binary](unified-binary.md) + - [Multi-Platform Adapters](multi-platform-adapters.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + - [Teams message reactions preview](teams-message-reactions-preview.md) + +--- + +## Context + +OpenAB has two paths for webhook-based chat platforms: + +1. **Unified:** platform adapters and Core run in one process. +2. **Standalone:** Core connects to `openab-gateway` through `/ws`. + +Before this decision, Core inferred behavior from adapter-wide methods and platform-name allowlists. That caused three classes of error: + +- a shared Unified adapter could apply Telegram streaming settings to Teams; +- Standalone Core could not know whether a Gateway acknowledged send, edit, or delete operations; +- a transport timeout could not be distinguished from an explicit platform rejection. + +The Standalone event channel is a bounded in-process broadcast channel. It has no durable inbox, replay, shared deduplication store, or consumer-group semantics. Multiple Core consumers therefore receive duplicate events rather than distributed work. + +## Decision + +### 1. Proposed baseline product decisions + +The following decisions define this proposed single-process baseline: + +| ID | Decision | +| --- | --- | +| D1 | The baseline supports one process replica and one active Standalone Core consumer per platform. Ingress after local enqueue is best-effort; there is no crash replay or exactly-once claim. A second consumer is warned at high severity and reported as unsupported, but is not rejected in this backward-compatible release. | +| D2 | Standalone uses an optional, client-initiated capability handshake. A peer that does not complete a supported handshake remains in legacy mode; missing ACKs are not delivery failures in legacy mode. | +| D3 | `GatewayEvent.event_id` is correlation metadata, never a platform activity ID. New↔new create/send returns the real platform message ID. Teams client presentation is outside transport acknowledgement semantics. | +| D4 | Teams supports Microsoft commercial public cloud only. Sovereign-cloud and custom-proxy endpoints require an explicit future cloud profile. | +| D5 | User-visible status and content streaming are independent capabilities. Teams processing status must not reuse a streaming placeholder implicitly. | + +### 2. Platform-aware capability contract + +Each adapter exposes capabilities for the actual `ChannelRef.platform`: + +```rust +struct AdapterCapabilities { + send_ack: bool, + edit_ack: bool, + delete_ack: bool, + supports_target_message_id: bool, + supports_reactions: bool, + can_edit: bool, + can_delete: bool, + streaming_mode: StreamingMode, + show_streaming_placeholder: bool, + message_limit: MessageLimit, + status_backend: StatusBackend, +} +``` + +Capability defaults fail closed: + +- no required ACK; +- no additive command-target field or native reaction support; +- no edit or delete support; +- streaming disabled; +- status side effects disabled; +- a conservative 4,096-character message limit. + +Direct adapters derive a backward-compatible capability view from their existing methods. Unified and Standalone shared adapters override it by platform. + +A valid negotiated hello is authoritative. If a platform is omitted from a valid hello, Core uses fail-closed defaults rather than optimistic legacy behavior. Legacy behavior is used only before a supported hello is accepted. + +### 3. Optional Standalone hello exchange + +Core sends this additive control frame immediately after connecting: + +```json +{ + "schema": "openab.gateway.client_hello.v1", + "protocol_version": 1, + "client_name": "openab-core/", + "requested_platforms": ["teams"] +} +``` + +A new Gateway responds: + +```json +{ + "schema": "openab.gateway.hello.v1", + "protocol_version": 1, + "capabilities": { + "teams": { + "send_ack": true, + "edit_ack": true, + "delete_ack": true, + "supports_target_message_id": true, + "supports_reactions": false, + "can_edit": true, + "can_delete": true, + "streaming_mode": "disabled", + "show_streaming_placeholder": true, + "message_limit": { "unit": "characters", "max": 4096 }, + "status_backend": "none" + } + }, + "topology": { + "active_consumers": 1, + "supported": true, + "delivery_mode": "best_effort_broadcast" + } +} +``` + +Rules: + +- unknown JSON fields are additive and may be ignored; +- an empty `requested_platforms` list requests all configured adapters; the stock Core uses this because one Standalone socket can carry events from several platforms; +- protocol version mismatch, malformed hello, or no hello keeps Core in legacy mode; +- Gateway continues to accept `openab.gateway.reply.v1` as the first frame, so an old Core works with a new Gateway; +- a new Core may send `client_hello` to an old Gateway; the old Gateway may log it as an invalid reply but must keep the connection usable; +- operations emitted before a valid hello is processed use legacy semantics; +- control frames are prioritized over broadcast events once received. + +Recommended Standalone rollout order remains Gateway first, then Core, but either side may be upgraded first. + +### 4. Structured write outcome + +Gateway keeps the existing `openab.gateway.response.v1` fields and adds optional fields: + +- `outcome`: `delivered`, `rejected`, or `unknown`; +- `error_code`; +- `retry_after_ms`. + +The internal result is: + +```rust +enum WriteOutcome { + Delivered { message_id: Option }, + Rejected { + code: String, + message: String, + retry_after_ms: Option, + }, + Unknown { code: String, message: String }, +} +``` + +Semantics: + +- create/send delivery requires a non-empty real message ID when that operation advertises required ACK support; +- edit/delete delivery does not require a message ID in its ACK; +- explicit platform refusal is `Rejected`; +- an ambiguous POST timeout or disconnect is `Unknown` and must not be retried blindly; +- legacy responses without `outcome` map from the existing `success`, `message_id`, and `error` fields; +- Core waits only for an operation whose capability advertises the corresponding ACK; +- Teams advertises `send_ack = true` only after its event-route send path emits a terminal structured response with a non-empty Bot Framework activity ID on delivery; +- Teams advertises edit/delete ACK and `supports_target_message_id` only after bot-owned mutation enforcement emits a terminal response on every command path; +- Teams advertises `supports_reactions = true` and `status_backend = reactions` only under the explicit public-preview `reactions_enabled` opt-in; the default remains false/`none`; +- `supports_reactions` is independent from the selected progress backend so permanent batch receipts can coexist with a processing message; new Core normalizes an old peer's `status_backend = reactions` to reaction support; +- configured Teams processing messages are selected Core-side only after a valid hello advertises required send/edit/delete ACKs, additive command targets, and bot-owned edit/delete; no valid hello means no message status; +- negotiated required ACK timeout defaults to 12 seconds and is configurable as `[gateway].gateway_ack_timeout_secs`; +- configuration rejects zero, a budget at or above `pool.prompt_hard_timeout_secs`, and a Teams budget at or below the 10-second Connector timeout; +- legacy response waits preserve their previous best-effort behavior. + +The 12-second Gateway budget must remain greater than the Teams Bot Connector request timeout (10 seconds) and less than the ACP turn hard timeout. + +### 5. Topology guardrail + +Each Gateway process counts active `/ws` Core consumers: + +- one consumer: `topology.supported = true`; +- more than one: emit an error-level log and return `topology.supported = false` with `delivery_mode = "best_effort_broadcast"`; +- disconnect decrements the count through a drop guard, including task cancellation paths. + +This detects unsupported fan-out inside one Gateway process. It cannot detect multiple independent Gateway replicas because the baseline deliberately has no shared state. The Helm deployments therefore remain fixed at `replicas: 1` with `Recreate` strategy. External deployments must follow the same constraint. + +Rejecting the second consumer would be a breaking change and is deferred. + +## Compatibility Matrix + +| Core | Gateway | Behavior | +| --- | --- | --- | +| old | old | Existing protocol and fire-and-forget behavior. | +| old | new | Gateway accepts a reply without hello; additive response fields are ignored. | +| new | old | Core sends optional hello, receives none, and stays in legacy mode; missing ACK is not failure. | +| new | new | Valid hello enables platform-aware capabilities, operation-specific required ACKs, structured outcomes, and topology reporting. | + +## Security and Reliability Boundaries + +- Capability negotiation is not authentication or authorization. Existing WebSocket token, platform webhook authentication, tenant checks, L2 scope, and L3 identity gates remain authoritative. +- Hello frames contain no platform credentials, service URLs, route records, or user identifiers. +- Advertising a capability does not make an operation safe by itself; the adapter must emit the corresponding ACK on every terminal path before the flag is enabled. +- `Unknown` preserves ambiguity instead of creating duplicates through automatic retry. +- This baseline does not claim durable enqueue, replay, duplicate-safe multi-consumer operation, or exactly-once delivery. + +## Consequences + +### Positive + +- Teams no longer inherits generic Gateway or Telegram streaming/status behavior; reaction availability, processing-message selection, and progressive content each require their own explicit opt-in and capability gate. +- Core no longer needs write-path platform allowlists such as `EDIT_RESPONSE_PLATFORMS`. +- New platform features can be introduced additively without forcing a lockstep Core/Gateway deployment. +- Operators and Core can identify unsupported multi-consumer topology. +- Delivery uncertainty is represented explicitly and can be handled without unsafe retry. + +### Negative + +- Capability DTOs are mirrored in Core and Gateway and require wire-compatibility tests. +- The first operation may use legacy behavior if it races ahead of hello processing. +- Existing adapters that cannot return a stable message ID must advertise conservative send-once behavior until their delivery path is upgraded. +- Cross-replica topology remains undetectable without a shared coordination system. + +## Alternatives Rejected + +1. **Platform-name allowlists in Core.** Rejected because they drift whenever an adapter changes behavior and cannot represent deployment-specific support. +2. **Mandatory hello before accepting replies.** Rejected because it breaks old Core deployments during rolling upgrade. +3. **Treat every timeout as rejection.** Rejected because the platform may have committed an ambiguous POST. +4. **Retry ambiguous POST automatically.** Rejected because it can duplicate user-visible activities. +5. **Reject the second consumer immediately.** Deferred because this is a breaking operational change. +6. **Claim HA from multiple broadcast consumers.** Rejected because broadcast fan-out is not work distribution and has no shared idempotency state. + +## Verification + +Automated verification must cover: + +- capability DTO defaults and wire round trips; +- all three structured outcomes plus legacy response decoding; +- old Core→new Gateway reply without hello; +- new Core→old Gateway legacy fallback; +- new↔new capability selection; +- requested-platform filtering; +- second-consumer unsupported topology and disconnect decrement; +- Unified Teams isolation from Telegram streaming settings; +- additive reaction-support decoding and processing-message fail-closed capability selection; +- Teams progressive-response selection only under explicit opt-in plus every required write primitive, in Standalone and Unified modes; +- configurable 12-second ACK default. diff --git a/docs/adr/teams-ephemeral-ingress-state.md b/docs/adr/teams-ephemeral-ingress-state.md new file mode 100644 index 000000000..28dd9f7b1 --- /dev/null +++ b/docs/adr/teams-ephemeral-ingress-state.md @@ -0,0 +1,151 @@ +# ADR: Teams Ephemeral Ingress Route and Duplicate Suppression + +- **Status:** Proposed +- **Date:** 2026-08-07 +- **Author:** @NeoHsu +- **Related:** + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Custom Gateway](custom-gateway.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + +--- + +## Context + +Bot Framework may retry the same webhook activity. Before this decision, the +Teams adapter published every authenticated retry, keyed reply routing only by +conversation ID, accepted messages with missing routing identifiers, and +ignored the result of the local Gateway broadcast. An HTTP 200 could therefore +mean that no Core consumer received the event. + +The proposed delivery boundary remains deliberately narrow: + +- one Gateway process replica; +- one supported Standalone Core consumer; +- process-local state only; +- no crash replay, durable inbox, shared idempotency store, or exactly-once + claim. + +## Decision + +### Required message fields + +A message activity proceeds only when it contains non-empty values for: + +- Bot Framework channel ID; +- tenant ID; +- conversation ID; +- activity ID; +- sender ID; +- service URL. + +Structural presence is checked before JWT key lookup; route and dedupe state are +created only after JWT and tenant authorization. The service URL must also pass +the Microsoft commercial public-cloud endpoint policy. Invalid or missing +fields return HTTP 400 and do not create route or dedupe state. Non-message and +structurally valid empty-text activities retain their existing HTTP 200 ignore +behavior. + +The adapter also parses optional Bot Framework `replyToId`, Team ID, and channel +ID into gateway-local route state. These values are not sent to the agent. + +### Composite identity + +Both route correlation and duplicate suppression use the composite identity: + +```text +(app_id, tenant_id, conversation_id, activity_id) +``` + +This prevents an activity ID collision from crossing applications, tenants, or +conversations. A generated `GatewayEvent.event_id` is a separate correlation +index for the future outbound route lookup. It is never a Bot Framework +activity ID. + +### Publication state machine + +Each composite key follows: + +```text +Vacant -> Publishing -> Accepted + | + +-> local publish failure -> Vacant +``` + +The first request owns publication. A concurrent duplicate that observes +`Publishing` waits on the same in-process completion signal. It returns HTTP +200 only if the owner reaches `Accepted`; otherwise it returns HTTP 503. + +An `Accepted` duplicate returns HTTP 200 without publishing another +`GatewayEvent`. A failed local broadcast removes the publishing entry before +returning HTTP 503, so a later Bot Framework retry may publish again. + +The Gateway checks the result of `broadcast::Sender::send` rather than a +separate receiver-count preflight, avoiding a check-then-send race. A successful +local broadcast is the process-local acknowledgement boundary; it does not prove ACP or +outbound completion. + +### Bounded process-local state + +Three positive settings control state: + +| Setting | Default | Purpose | +| --- | ---: | --- | +| `teams.dedupe_ttl_secs` | 600 | Accepted duplicate suppression window | +| `teams.route_ttl_secs` | 3600 | Authenticated ephemeral route lifetime | +| `teams.max_route_entries` | 10000 | Independent capacity bound for route and dedupe maps; bot-owned mutation state uses the same independent bound | + +Equivalent Standalone environment variables are +`TEAMS_DEDUPE_TTL_SECS`, `TEAMS_ROUTE_TTL_SECS`, and +`TEAMS_MAX_ROUTE_ENTRIES`. + +Expired entries are removed during reservation and by a shared background +sweeper used in both Standalone and Unified mode. At capacity, the oldest +accepted dedupe entry or route may be evicted with a warning. Active +`Publishing` entries are never evicted to admit another key; saturation returns +HTTP 503. A stale publishing owner is failed after a bounded internal timeout so +waiters cannot remain blocked indefinitely. + +The initial route implementation retained the legacy conversation-to-service-URL +cache for the existing outbound path. The real-send implementation removed that +compatibility cache: outbound sends +now resolve the authenticated route directly by `event_id` and verify the +reply's conversation before using its gateway-local service URL. + +## Security and privacy + +- Service URLs remain gateway-local and are excluded from Gateway wire events, + agent prompts, response payloads, and logs. +- Endpoint validation happens before route persistence. +- Logs may include tenant, conversation, sender, and validated service host, + but never the full service URL or app secret. +- Route state is not promoted to a proactive conversation reference. +- Duplicate suppression occurs only after JWT and tenant checks; unauthenticated + input cannot poison the cache. + +## Compatibility + +This is a correctness change for malformed or undeliverable Teams webhooks: + +| Condition | Previous behavior | New behavior | +| --- | --- | --- | +| Missing required route field | Often HTTP 200 | HTTP 400 | +| Accepted duplicate | Published again | HTTP 200 without republish | +| No local event consumer | HTTP 200 | HTTP 503, no tombstone | +| Local publish succeeds | HTTP 200 | HTTP 200 and route accepted | + +No Gateway wire field is removed or made mandatory. Unified and Standalone use +the same adapter state machine. Existing configuration remains valid through +the documented defaults. + + +## Consequences + +- Duplicate suppression does not survive restart and does not span replicas. +- Capacity eviction may shorten the effective dedupe window under sustained + overload; warnings make this visible. +- A successful broadcast can still be lost after process failure or consumer + lag. Durable delivery requires a later inbox/outbox design. +- [Real send acknowledgement](teams-real-send-acknowledgement.md) uses this + route for real activity IDs and outbound correlation. diff --git a/docs/adr/teams-message-reactions-preview.md b/docs/adr/teams-message-reactions-preview.md new file mode 100644 index 000000000..2012ca64d --- /dev/null +++ b/docs/adr/teams-message-reactions-preview.md @@ -0,0 +1,133 @@ +# ADR: Teams Public-Preview Message Reactions + +- **Status:** Proposed +- **Date:** 2026-08-09 +- **Author:** @NeoHsu +- **Related:** + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + +--- + +## Context + +Microsoft's Teams SDK exposes public-preview add/remove reaction operations on +the Bot Connector conversation API. They use the authenticated Bot Framework +`serviceUrl`, conversation ID, activity ID, and bot token already required for +normal sends. They do not require Microsoft Graph, RSC, a delegated user token, +or a new manifest permission. + +OpenAB previously treated `add_reaction` and `remove_reaction` as successful +no-ops for Teams. Enabling the preview by default would change existing +behavior, create new status side effects, and rely on a tenant feature that is +not yet generally available. + +## Decision + +### Explicit opt-in + +Add this first-class setting: + +```toml +[teams] +reactions_enabled = false +``` + +The environment fallback is `TEAMS_REACTIONS_ENABLED`. Only `true` or `1` +enables the preview. Missing, false, zero, empty, or invalid values remain +fail-closed at `false`. + +When disabled: + +- reaction commands preserve the legacy successful no-op; +- no route lookup, token request, or Connector write occurs; +- Teams advertises `status_backend = none` to a negotiated Core. + +When enabled, Standalone and Unified advertise `supports_reactions = true`. +They select `status_backend = reactions` unless Core explicitly selects a +separate processing-message backend. In that combined mode, reactions provide +only permanent queued receipts while a turn-local message provides transient +progress. Streaming remains disabled and reaction support does not enable any +other Teams capability. + +### Bot Connector operations + +OpenAB uses the existing Bot Framework token and validated public-cloud +`serviceUrl`: + +```text +PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType} +DELETE {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}/reactions/{reactionType} +``` + +All path values are appended as URL path segments. Empty reaction writes send +`Content-Length: 0`; the Connector otherwise rejects PUT requests that have +neither content length nor chunked framing. The +existing same-origin redirect policy, timeout, bounded error body, token +redaction, and commercial public-cloud endpoint policy apply unchanged. + +Reaction writes share the idempotent PUT/DELETE outcome classifier. HTTP +success is `Delivered`; explicit 3xx/4xx is `Rejected`; timeout, disconnect, or +5xx is `Unknown`. An explicit `429` with `Retry-After` no greater than one +second receives at most one internal retry. A bounded retry emits a warning +containing only the static operation name and `retry_after_ms`; it does not log +the Connector URL, conversation, activity ID, or token. There is no +POST/fresh-send fallback. + +### Scope and target trust + +A reaction target may be: + +- an authenticated inbound activity retained in the process-local route index; +- the authenticated reply-chain root of the command's origin route; or +- a confirmed bot-owned activity in the process-local ownership index. + +New-field commands require a live origin event route and cannot cross app, +tenant, or conversation scope. Legacy commands without a separate origin are +accepted only when app, conversation, and activity resolve to one unique route; +cross-tenant ambiguity fails closed. + +Reaction writes use the same fixed tenant/conversation write shards as sends, +edits, and deletes, with route state revalidated after lock acquisition. + +### Reaction IDs + +Core emits Unicode status emoji, while the Connector preview expects Teams +reaction IDs. OpenAB maps all default status and completion emoji to IDs from +the Microsoft Teams reactions reference. The generic controller's hard-coded +soft- and hard-stall states are included: `🥱` maps to +`1f971_yawningface`, while `😨` maps to the distinct `fearful` ID so a later +`😱` / `screamingfear` error swap cannot add and remove the same reaction. A +configured value may also be an ASCII reaction ID containing only letters, +digits, `_`, or `-`, up to 128 bytes. Unknown Unicode and unsafe identifiers +are rejected before HTTP. + +### Deliberate exclusions + +This preview slice does not: + +- process inbound `messageReaction` activities; +- add Graph or RSC permissions; +- add reaction-specific required ACK negotiation; +- promise availability outside Microsoft commercial public cloud; +- make native reactions part of the default processing-indicator contract; +- persist route evidence across restart or replicas. + + +## Consequences + +### Positive + +- Operators can test visible Teams reactions without widening Graph authority. +- Existing deployments remain unchanged until explicit opt-in. +- Reactions cannot be aimed at arbitrary activity IDs or leak `serviceUrl`. +- Default OpenAB status emoji work without operator-specific mapping. + +### Negative + +- Preview availability and rendering remain tenant-dependent. +- Rapid generic status transitions may encounter the platform's reaction rate + limit; bounded retries do not guarantee every cosmetic transition is shown. +- Inbound user reactions remain ignored until a separate behavior and trust + contract is approved. diff --git a/docs/adr/teams-owned-message-mutations.md b/docs/adr/teams-owned-message-mutations.md new file mode 100644 index 000000000..94e0357a3 --- /dev/null +++ b/docs/adr/teams-owned-message-mutations.md @@ -0,0 +1,229 @@ +# ADR: Teams Bot-Owned Message Mutations + +- **Status:** Proposed +- **Date:** 2026-08-09 +- **Author:** @NeoHsu +- **Related:** + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams public-preview message reactions](teams-message-reactions-preview.md) + +--- + +## Context + +The [real-send decision](teams-real-send-acknowledgement.md) returns the Bot +Framework activity ID for every confirmed Teams send. +That ID is required for Bot Connector update and delete APIs, but accepting an +arbitrary activity ID from Core would allow OpenAB to attempt mutation of an +inbound user message or a bot message from another tenant or conversation. + +The existing `GatewayReply.reply_to` field is overloaded by older command +paths: normal sends use it as the origin OpenAB event ID, while edit and delete +commands historically place the platform message target in it. Keeping that +overload for new peers would again risk passing an OpenAB event ID to a Bot +Connector activity URL. + +The reliability boundary remains single-process and process-local. It does not +provide durable ownership, cross-replica coordination, or mutation of messages +created before restart. + +## Decision + +### Additive command target + +`openab.gateway.reply.v1` gains an optional `target_message_id` field. The +capability contract gains a fail-closed `supports_target_message_id` flag. + +For a negotiated peer that advertises support, Core sends command replies as: + +```json +{ + "reply_to": "evt_origin", + "target_message_id": "platform_activity_id", + "command": "edit_message" +} +``` + +`reply_to` remains origin event correlation. `target_message_id` is the +platform activity targeted by edit, delete, or an opt-in reaction command. +Normal sends never interpret `reply_to` as a command target. + +Compatibility behavior is: + +- new Core + new Gateway: preserve the origin event and send the explicit + target field; +- new Core + old Gateway, or an operation before hello completes: omit the new + field and copy the command target into legacy `reply_to`; +- old Core + new Gateway: when the field is absent, treat `reply_to` as the + legacy command target; +- Unified: use the explicit field for Teams and legacy form for adapters that + do not advertise support. + +Missing capability fields default to false. The protocol version remains v1 +because both capability and reply fields are additive and covered by +old-peer decoding tests. + +### Process-local ownership index + +After a Teams create/send returns `Delivered` with a non-empty activity ID, the +Gateway records: + +```text +(app_id, tenant_id, conversation_id, activity_id) + -> authenticated route + ownership_created_at +``` + +The ownership index: + +- is process-local; +- uses `teams.route_ttl_secs` as its lifetime; +- has an independent `teams.max_route_entries` capacity bound; +- evicts its oldest entry at capacity with an operator warning; +- is swept by the existing Teams ingress cleanup task; +- stores the already validated gateway-local service URL and never exposes it + to Core, the agent, ACK payloads, or logs. + +Inbound activity IDs are not inserted. Only a confirmed outbound activity can +be edited or deleted. + +A new-field command must still have a live origin event route. The route fixes +the app, tenant, and conversation scope before ownership lookup. A missing or +expired origin returns `target_origin_not_found`; a channel mismatch returns +`target_scope_mismatch`; a target outside that exact scope returns +`message_not_owned`. + +A legacy command has no separate origin event. Gateway may use a unique owned +entry matching the configured app, command conversation, and target activity. +If the same legacy tuple exists in more than one tenant, it fails closed with +`target_scope_ambiguous`. + +### Bot Connector operations + +Owned edits call: + +```text +PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId} +``` + +Owned deletes call: + +```text +DELETE {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId} +``` + +Conversation and activity IDs continue to use URL path-segment encoding and the +commercial-public-cloud endpoint policy. OAuth acquisition, same-origin +redirect policy, 5-second connect timeout, 10-second request timeout, 4 KiB +error body cap, and redaction rules are unchanged. + +Mutation outcomes are classified as follows: + +- HTTP success: `Delivered` without a required message ID; +- explicit `3xx` or `4xx`: `Rejected`; +- `429`: `Rejected` with parsed `retry_after_ms` unless the bounded internal + retry succeeds; +- `5xx`, request timeout, disconnect, or transport failure: `Unknown`; +- route, ownership, target, or command validation failure before HTTP: + `Rejected`. + +POST sends are never retried. PUT and DELETE perform at most one internal retry +only after an explicit `429` response proves that attempt was rejected, and +only when `Retry-After` is present and no greater than one second. A second +`429`, a longer delay, a timeout, disconnect, or `5xx` is returned immediately. +Core does not retry a terminal `Rejected` or `Unknown` outcome. + +A delivered delete removes the ownership entry. A rejected delete retains it +for a later corrected attempt; an unknown delete retains it because the Gateway +cannot safely infer whether Teams applied the operation. Delivered edits retain +the original ownership timestamp rather than turning active mutation into +unbounded retention. + +### Operation-specific acknowledgement + +A configured Teams adapter advertises: + +```text +send_ack = true +edit_ack = true +delete_ack = true +supports_target_message_id = true +can_edit = true +can_delete = true +streaming_mode = disabled +``` + +New Standalone peers wait for the existing configured Gateway ACK timeout on +edit and delete. Delivered edit/delete ACKs do not require a message ID. +Rejected and unknown outcomes propagate as errors and are not converted to +fire-and-forget success. + +Legacy peers retain their previous missing-ACK semantics. Unified returns the +same outcome directly and overrides native delete instead of falling back to an +edit-to-zero-width operation. + +Enabling edit/delete capability does not enable progressive response. Teams +`streaming_mode` remains disabled; progressive response owns its policy and +lifecycle separately. + +### Same-conversation ordering + +Every Teams send, edit, and delete acquires a fixed process-local write shard +computed from tenant and conversation ID. There are 64 shards: + +- writes in the same tenant/conversation are serialized; +- different conversations normally proceed independently; +- a hash collision may conservatively serialize unrelated conversations; +- the fixed array prevents an attacker or busy tenant from growing a lock map + without bound. + +Ownership and route state are revalidated after lock acquisition. This prevents +a queued edit from running after a preceding delete removed ownership. + +## Compatibility matrix + +| Core | Gateway | Command targeting and ACK behavior | +| --- | --- | --- | +| old | old | Legacy `reply_to` wire shape; Teams edit/delete remain unsupported and fail closed in a legacy Gateway without bot-owned mutation support. | +| old | new | Legacy target fallback is accepted only if it resolves to a unique bot-owned activity; missing ACK remains non-fatal to old Core. | +| new | old | No target-field capability is advertised, so Core copies the target into legacy `reply_to`; missing required ACK is not enabled. | +| new | new | Origin and target remain separate; ownership is enforced; edit/delete receive operation-specific structured ACKs. | +| Unified | embedded | Teams uses the explicit target and returns the structured mutation outcome directly. | + +## Security and reliability boundaries + +- Inbound user activities cannot enter the ownership index and therefore cannot + be edited or deleted. +- New-field mutation cannot cross app, tenant, or conversation scope. +- Ambiguous legacy scope fails closed. +- Service URLs and credentials remain Gateway-local. +- Ownership disappears on restart and is not shared across replicas. +- A successful write ACK is not a durable event log or exactly-once guarantee. +- External deletion, app uninstall, or platform-side retention can make a + locally owned ID invalid; the Connector response remains authoritative. +- This decision does not add proactive references, persistent ownership, or + multi-consumer coordination. + + +## Consequences + +### Positive + +- OpenAB can update and delete only activities it confirmed creating. +- Event correlation and platform command targets are no longer overloaded for + new peers. +- Edit/delete uncertainty is explicit and cannot trigger blind fresh sends. +- Same-conversation writes cannot overtake one another within a process. +- Rolling upgrades remain non-lockstep. + +### Negative + +- Restart, TTL expiry, or capacity eviction makes older bot messages immutable + through OpenAB. +- New-field commands require their origin route to remain live even if ownership + was recorded slightly later. +- Fixed lock-shard collisions can reduce concurrency. +- The one-second `429` retry bound may return a retryable rejection to Core + instead of waiting for a longer platform delay. +- Connector mutation behavior and availability remain controlled by Microsoft. diff --git a/docs/adr/teams-real-send-acknowledgement.md b/docs/adr/teams-real-send-acknowledgement.md new file mode 100644 index 000000000..044acb2dd --- /dev/null +++ b/docs/adr/teams-real-send-acknowledgement.md @@ -0,0 +1,205 @@ +# ADR: Teams Real Send Acknowledgement and Reply Correlation + +- **Status:** Proposed +- **Date:** 2026-08-08 +- **Author:** @NeoHsu +- **Related:** + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Custom Gateway](custom-gateway.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + +--- + +## Context + +The [ephemeral-ingress decision](teams-ephemeral-ingress-state.md) introduced an +authenticated, bounded, gateway-local route for each Teams message activity. Before this decision, outbound Teams delivery still used a +conversation-only service URL cache, copied the OpenAB `event_id` into Bot +Connector `replyToId`, and discarded the activity ID returned by Teams in +Unified mode. Standalone Core could not require a send acknowledgement because +the Teams capability advertised `send_ack = false`. + +Those behaviors violated the identifier contract: + +- `GatewayEvent.event_id` is OpenAB correlation metadata, not a Bot Framework + activity ID; +- a successful create/send must return the real platform activity ID; +- a timed-out or disconnected POST may already have completed and must not be + represented as a safe rejection or retried blindly. + +Teams controls channel-root, channel-reply, Personal, and group-chat +presentation; HTTP transport tests cannot define or guarantee that UX. + +## Decision + +### Route resolution + +A commandless outbound reply resolves `GatewayReply.reply_to` exclusively as an +OpenAB `event_id` in the bounded ingress registry. The resolved route supplies: + +- bot app and tenant scope; +- conversation ID and type; +- inbound activity and reply-chain identifiers; +- the validated gateway-local service URL; +- optional Team and channel identifiers. + +The outbound `GatewayReply.channel.id` must equal the route's conversation ID. +A missing, expired, or capacity-evicted event route returns +`route_not_found`; a conversation mismatch returns `route_mismatch`. Both are +`Rejected` outcomes and occur before OAuth or Bot Connector I/O. + +The conversation-only compatibility service URL map is removed. Service URLs +remain inside authenticated route state and are never sent to Core or the +agent. + +### Normal send and explicit quote + +Normal responses do not infer a Bot Connector `replyToId` from `event_id` or +from the triggering inbound activity. They use the Bot Connector +`SendToConversation` endpoint: + +```text +POST {serviceUrl}/v3/conversations/{conversationId}/activities +``` + +Only `GatewayReply.quote_message_id` expresses an explicit quote request. The +adapter uses the Bot Connector `ReplyToActivity` endpoint and also carries the +target as `Activity.replyToId`, but only when the target is known in the same +app, tenant, and conversation scope: + +```text +POST {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId} +``` + +Known targets include: + +- the current authenticated inbound activity; +- its authenticated `replyToId` reply-chain root; +- another activity still present in the same bounded ingress route index. + +An empty or unknown target falls back to a plain send. It is never looked up in +another tenant or conversation and never causes `event_id` to reach a Bot +Connector activity URL or body field. This endpoint distinction follows +Microsoft's [Bot Connector API reference](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0#reply-to-activity), +which directs replies to a specific activity through `ReplyToActivity` rather +than `SendToConversation`. + +### Structured send outcomes + +Teams sends produce one of the existing protocol outcomes: + +| Condition | Outcome | Code / data | +| --- | --- | --- | +| HTTP success with non-empty activity ID | `Delivered` | real `message_id` | +| Invalid or missing route | `Rejected` | `invalid_route`, `route_not_found`, or `route_mismatch` | +| OAuth acquisition fails before Connector POST | `Rejected` | `connector_auth_failed` | +| Connector `3xx` or `4xx` response | `Rejected` | stable rejection code | +| Connector `429` | `Rejected` | `rate_limited` plus bounded `retry_after_ms` | +| Connector `5xx` | `Unknown` | `connector_server_error` | +| POST timeout, disconnect, or transport error | `Unknown` | `request_timeout` or `transport_error` | +| Success response is malformed or omits activity ID | `Unknown` | `invalid_success_response` or `missing_activity_id` | + +A success response without a usable activity ID is `Unknown`, not `Rejected`, +because Teams may already have created the message. OpenAB does not +fresh-send or automatically retry `Rejected` or `Unknown` results on this path. +Error body size, redaction, endpoint validation, redirect policy, and request +timeouts remain governed by the Bot Connector transport-hardening decision. + +`Retry-After` accepts either delta seconds or an HTTP date and is converted to a +bounded millisecond value. Recording it does not introduce an automatic retry. + +### Standalone acknowledgement + +A configured Teams adapter now advertises: + +```text +send_ack = true +edit_ack = false +delete_ack = false +``` + +After a valid new↔new hello negotiation, Core includes a request ID and waits for +the operation-specific send ACK. Gateway emits +`openab.gateway.response.v1` with additive structured outcome fields on every +terminal commandless-send path. `Delivered` must contain a non-empty real +Bot Framework activity ID; Core rejects an otherwise successful ACK without +one. + +A legacy Core may omit the request ID or include one for its existing +best-effort streaming correlation. New Gateway emits no unsolicited frame when +the ID is absent. When it is present, the response keeps the legacy fields and +adds outcome metadata that old peers can ignore; a missing response remains +non-fatal under legacy Core semantics. New Core connected to an old Gateway +receives no supported hello and likewise retains legacy missing-ACK behavior. + +### Unified acknowledgement + +Unified mode calls the same Teams route and Connector implementation in +process. `ChatAdapter::send_message` and `send_message_with_reply` return a +`MessageRef` containing the real Bot Framework activity ID. `Rejected` and +`Unknown` outcomes become errors; Unified no longer fabricates a synthetic ID +for Teams delivery. + +Other Unified platforms retain their existing behavior. At this decision's +boundary, Teams capabilities report required send acknowledgement while edit, +delete, streaming, and status remain disabled. The later +[bot-owned mutation decision](teams-owned-message-mutations.md) enables guarded +edit/delete without enabling streaming. + +### Activity DTO + +The inbound DTO parses the route and presentation fields, including +`activity.id`, `activity.replyToId`, `conversation.id`, `conversation.conversationType`, +`channelData.team.id`, `channelData.channel.id`, and `recipient.id`. +Parsing these fields does not define or guarantee Teams presentation behavior. + +## Compatibility + +| Core | Gateway | Send behavior | +| --- | --- | --- | +| old | old | Existing legacy fire-and-forget behavior. | +| old | new | Event route is used; no request ID means no response frame, while a legacy request ID receives a backward-compatible response. Missing ACK remains non-fatal to old Core. | +| new | old | No valid hello; Core keeps legacy missing-ACK semantics. | +| new | new | Teams advertises required send ACK and returns a structured terminal outcome with the real activity ID on delivery. | +| Unified | embedded | The same outcome is returned directly without a WebSocket ACK frame. | + +Operations emitted before a valid hello is processed continue to use legacy +Core semantics. The Gateway still classifies the internal result, but does not +send an unsolicited response when the reply has no request ID. + +## Security and reliability boundaries + +- Route lookup is process-local, bounded, and TTL-limited. +- Service URLs never cross the Gateway boundary or appear in outcome messages. +- Quote targets cannot cross app, tenant, or conversation scope. +- Unsupported commands are rejected before route lookup and network I/O; + reactions remain an intentional no-op until a Teams status backend exists. +- A Gateway broadcast ACK is not a durable record and does not provide crash + replay. +- This decision does not claim exactly-once delivery, multi-consumer work + distribution, proactive-send support, or restart-persistent message + ownership. + + +## Consequences + +### Positive + +- OpenAB correlation IDs can no longer leak into Bot Connector activity fields. +- New Standalone and Unified sends expose the real Teams activity ID. +- Rejection and ambiguous delivery are distinct, preventing blind duplicate + sends after POST uncertainty. +- The temporary conversation-only service URL cache is eliminated. +- Explicit quote targets fail safely to plain send when route evidence is + missing. + +### Negative + +- Replies fail after route expiry, capacity eviction, or process restart. +- A successful Teams write with a malformed response is reported as unknown + even though a message may be visible to the user. +- Teams clients control scope-specific thread and quote presentation; successful + transport correlation does not guarantee visible quote chrome. +- Required ACKs make Connector latency visible to new Core peers and consume + the configured 12-second Gateway ACK budget. diff --git a/docs/adr/teams-typed-scope-and-mention-routing.md b/docs/adr/teams-typed-scope-and-mention-routing.md new file mode 100644 index 000000000..c1345b5c1 --- /dev/null +++ b/docs/adr/teams-typed-scope-and-mention-routing.md @@ -0,0 +1,191 @@ +# ADR: Teams Typed Scope and Mention Routing + +- **Status:** Proposed +- **Date:** 2026-08-07 +- **Author:** @NeoHsu +- **Related:** + - [Multi-platform adapter architecture](multi-platform-adapters.md) + - [Identity trust-none](identity-trust-none.md) + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + +--- + +## Context + +Before typed scope was added, Teams published only a conversation route, +sender, raw text, and an empty mention list. Core therefore evaluated every Gateway event with +`is_dm = false`, could not distinguish Personal, group chat, and Team channel +scope, and could not prove that a structured Teams mention targeted the +receiving bot. Pure text could also resemble an `@mention` without carrying an +authenticated mention entity. + +This proposal must add scope and mention evidence without changing outbound +routing, widening Graph authority, or requiring a lockstep Core/Gateway rollout. + +## Decision + +### Additive wire fields + +`openab.gateway.event.v1` gains three optional fields. The schema and protocol +version remain unchanged because old decoders ignore unknown fields and new +decoders default absent fields: + +```rust +struct GatewayScope { + tenant_id: Option, + team_id: Option, + channel_id: Option, + conversation_type: String, + trust_scope_id: String, + is_dm: bool, +} + +struct RecipientInfo { + id: String, + name: String, +} + +struct MentionInfo { + id: String, + text: String, +} + +struct GatewayEvent { + // existing fields remain unchanged + scope: Option, + recipient: Option, + mention_entities: Vec, +} +``` + +The existing `mentions` array remains a list of mentioned entity IDs for +cross-platform structural gating. `mention_entities` carries the exact Teams +entity text needed for safe recipient-mention removal. Neither field carries a +service URL, token, or proactive conversation reference. + +`ChannelInfo.id` remains the Bot Connector conversation ID used for session and +outbound routing. It must not be replaced by `trust_scope_id`. + +### Teams scope derivation + +After JWT, tenant, required-route, and public-cloud service URL validation, the +Gateway canonicalizes known Bot Framework conversation types: + +| `conversationType` | `is_dm` | Required typed fields | Opaque `trust_scope_id` shape | +| --- | --- | --- | --- | +| `personal` | `true` | tenant + conversation | `teams:{tenant}:personal:{conversation}` | +| `groupChat` | `false` | tenant + conversation | `teams:{tenant}:group-chat:{conversation}` | +| `channel` | `false` | tenant + Team + channel | `teams:{tenant}:team:{team}:channel:{channel}` | + +The key is opaque and is never parsed for authorization. Raw Team and channel +IDs remain separate fields for allowlist matching. Unknown conversation types, +`is_dm` contradictions, empty `trust_scope_id`, or channel scope missing Team or +channel IDs fail closed in the new Core. + +### Scope policy and compatibility + +First-class Teams scope settings are: + +```toml +[teams] +allowed_teams = [] +allowed_channels = [] +allow_personal = true +allow_group_chats = true +``` + +Rules: + +- Personal is admitted only when `allow_personal = true`. +- Group chat is admitted only when `allow_group_chats = true`. +- For Team channels, both lists empty means L2 open. If either list is non-empty, + a Team ID or channel ID match admits the scope. +- L3 `allowed_users` remains an independent security gate and is never bypassed + by an L2 scope match. + +Presence of any new Teams scope field or corresponding environment variable +opts into typed policy. If none is present, Core preserves the existing generic +Gateway L2 behavior: `GATEWAY_ALLOWED_CHANNELS` and `[gateway].allowed_channels` +continue matching `ChannelInfo.id` (the conversation ID). This legacy fallback +is explicit and observable; it prevents a rolling upgrade from silently +opening or closing an existing deployment. New Gateway events without a new +Core are ignored additively. New Core receiving an old event without `scope` +uses the legacy gate. + +### Mention trust and trigger matrix + +The Gateway parses only `Activity.entities[]` entries whose type is `mention` +and whose `mentioned.id` is non-empty. It publishes their IDs in `mentions` and +their ID/text pairs in `mention_entities`. `Activity.recipient.id` is published +separately. + +New↔new trigger behavior is: + +| Scope | Trigger | +| --- | --- | +| Personal | Every otherwise trusted user message; mention not required | +| Group chat | A structured mention entity has `mentioned.id == recipient.id` | +| Team channel root/reply | A structured mention entity has `mentioned.id == recipient.id` | +| Unknown/malformed scope | Drop before commands, sessions, reactions, or ACP | + +Pure text such as `@OpenAB` or `OpenAB` without a matching entity never +satisfies group/channel mention gating. Thread presence does not bypass Teams +mention gating; ambient/RSC reading remains a separate feature. + +After structural mention and L2/L3 trust gates allow the event, Core removes +only entity text associated with the recipient bot ID. It maps entity order to +text occurrences, removes matched recipient ranges in reverse order, and trims +only the resulting edges. Other user/bot mentions and arbitrary whitespace are +preserved. A malformed recipient entity may trigger by ID but is not removed +unless its exact non-empty text occurs. Mention-only text is ignored when no +attachment blocks remain. + +Core sets `SenderContext.receiver_id` from `recipient.id`; sender identity, +conversation routing, event correlation, and message ID semantics remain +unchanged. + +## Security and reliability boundaries + +- Scope and mention evidence is created only after existing Bot Framework JWT, + tenant, and service URL validation. +- Mention markup is not authority; structured entity IDs are. +- Scope allowlists do not replace L3 user trust. +- This decision adds no Graph, RSC, delegated token, manifest permission, ambient + reading, attachment download, or persistent conversation state. +- Service URLs and credentials remain Gateway-local. +- Standalone and Unified paths use the same Core scope, mention, and prompt + normalization helpers. + +## Acceptance criteria + +Automated tests must cover: + +- additive new/old wire decoding; +- Personal, groupChat, and channel scope derivation; +- missing Team/channel fields and unknown conversation types; +- typed Team-or-channel allowlist semantics and legacy conversation fallback; +- correct `is_dm` and L3 identity ordering; +- genuine recipient mention, pure-text spoof, reply mention, multi-mention, + duplicate mention, and malformed entity cases; +- removal of only the recipient mention while preserving other text; +- slash-command recognition after recipient mention removal; +- Standalone and Unified use of the same helpers; +- config, environment fallback, Helm, and platform-schema conformance. + +## Consequences + +### Positive + +- Core receives an explicit, authenticated Teams scope instead of guessing from + a route ID. +- Structured mention gating prevents textual mention spoofing. +- Personal behavior remains mention-free and existing generic L2 restrictions + retain a non-lockstep fallback. +- Agent context can identify the receiving bot in multi-agent deployments. + +### Negative + +- Typed Teams L2 policy requires additional Core configuration and tests. +- Old Core cannot enforce new group/channel mention semantics until upgraded. +- Mention text lacks explicit character offsets, so cleanup relies on entity + order plus exact text matching and deliberately leaves unmatched markup. From 6c49644ea22e79216b6f51c047c9845f2e51b70d Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:50:47 +0800 Subject: [PATCH 02/16] docs(teams): propose response lifecycle decisions --- docs/adr/teams-attachment-ingress.md | 305 ++++++++++++++++++ .../adr/teams-formatting-and-long-messages.md | 284 ++++++++++++++++ docs/adr/teams-processing-indicator.md | 201 ++++++++++++ docs/adr/teams-progressive-response.md | 220 +++++++++++++ 4 files changed, 1010 insertions(+) create mode 100644 docs/adr/teams-attachment-ingress.md create mode 100644 docs/adr/teams-formatting-and-long-messages.md create mode 100644 docs/adr/teams-processing-indicator.md create mode 100644 docs/adr/teams-progressive-response.md diff --git a/docs/adr/teams-attachment-ingress.md b/docs/adr/teams-attachment-ingress.md new file mode 100644 index 000000000..f25d3cf8c --- /dev/null +++ b/docs/adr/teams-attachment-ingress.md @@ -0,0 +1,305 @@ +# ADR: Teams Metadata-First Attachment Ingress + +- **Status:** Proposed +- **Date:** 2026-08-09 +- **Author:** @NeoHsu +- **Related:** + - [Inbound attachments](../inbound-attachments.md) + - [Custom Gateway](custom-gateway.md) + - [Gateway capabilities and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + - [Teams typed scope and mention routing](teams-typed-scope-and-mention-routing.md) + - [Microsoft: Send and receive files](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4) + +--- + +## Context + +Teams message activities can carry inline images and, in Personal chats with a +separate `supportsFiles = true` manifest profile, +`application/vnd.microsoft.teams.file.download.info` attachments. Microsoft +states that file-consent APIs support Personal context only; wider file support +requires Microsoft Graph. This proposal remains Graph-free, so it supports only +inline images and Personal image or UTF-8 text files. + +The existing Gateway attachment envelope can carry base64 bytes or a local +path. Neither existing delivery mode is sufficient as the Teams security +contract: + +- Gateway receives Microsoft credentials and download URLs, but it does not own + Core's configured L2 scope and L3 identity decision. +- Core owns the authoritative trust gate, but Microsoft credentials and URLs + must not cross into Core or the ACP child. +- A Gateway-local path is invalid when Standalone Gateway and Core use separate + filesystems. +- Downloading before the Core gate would let an authenticated but untrusted + sender consume network, memory, and image-processing resources. + +The attachment-ingress design therefore needs a two-phase contract that works +identically in Unified and Standalone deployments while preserving default-off behavior and +rolling compatibility. + +## Decision + +### Choose metadata-first materialization + +Use metadata-first materialization: Gateway publishes bounded attachment +metadata plus an opaque reference, and Core asks Gateway to materialize that +reference only after the shared trust gate returns Allow. + +Do not copy Core trust configuration into Gateway. A duplicated evaluator would +create two policy authorities and could drift across rolling upgrades. The +existing Core gate remains the sole L2/L3 decision point. + +The flow is: + +```text +Microsoft activity + -> Gateway validates JWT, tenant, route fields, and service URL + -> Gateway stores URL/auth metadata only in the bounded process-local route + -> GatewayEvent carries filename/MIME/declared size + opaque reference + -> Core runs structural filter, typed L2, and shared L3 + -> Deny: no materialization command and no download + -> Allow: Core requests each reference sequentially + -> Gateway validates event/route/reference ownership, then downloads + -> Gateway returns bounded normalized attachment bytes or rejected metadata + -> Core converts the result to ACP ContentBlocks before dispatcher admission +``` + +No ACP session, processing indicator, streaming placeholder, or proactive +promotion begins before materialization completes. + +### Additive v1 wire fields and capability + +Keep the existing schema and protocol version. Add only optional fields: + +- `Attachment.reference`: opaque Gateway-generated materialization reference; +- `GatewayReply.attachment_ref`: requested opaque reference; +- `GatewayResponse.attachment`: one normalized attachment result; and +- `AdapterCapabilities.supports_attachment_materialization`: fail-closed + operation capability. + +The operation is `command = "materialize_attachment"`. It always carries a +non-empty `request_id`; Core sends it only after a valid hello explicitly +advertises the capability. Unknown or legacy peers are never probed +optimistically. + +`reply_to` remains the authenticated Gateway event correlation, and +`channel.id` remains the conversation correlation. Gateway must validate both +against the process-local route before resolving `attachment_ref`. A reference +is random, contains no URL or platform ID, is scoped to one event, and expires +with that event's route. It is not promoted to proactive state and does not +survive restart or replica changes. + +Core makes at most one materialization request per reference in a turn and does +not automatically retry timeout, disconnect, malformed response, or rejection. +A download is read-only at Microsoft, but unbounded retry would still amplify +attacker-controlled resource use. + +### One bounded base64 response for both deployment modes + +Both Standalone and Unified return normalized bytes in +`GatewayResponse.attachment.data` as bounded base64. Unified deliberately uses +the same logical transport instead of a Gateway-local path so both modes share +validation, limits, failure behavior, and tests. + +Existing `Attachment.path` remains available to other colocated adapters; Teams +attachment ingress neither emits nor accepts a path. This prevents a Standalone +Gateway path from being interpreted in the Core container. + +The materialized response must fit the explicit internal WebSocket frame cap. +Core rejects an oversized response before JSON or base64 processing. The URL, +query, OAuth token, and raw Microsoft attachment object never enter the event, +response, Core log, agent prompt, or ACP child environment. + +### Explicit opt-in + +Add one first-class Teams setting: + +```toml +[teams] +inbound_attachments = false +``` + +The environment fallback is `TEAMS_INBOUND_ATTACHMENTS`. Only `true`, `false`, +`1`, and `0` are accepted; malformed values fail closed to `false`. The default +remains disabled. + +When disabled, Teams text behavior is unchanged and attachment metadata is +ignored. An attachment-only activity creates no Core turn. Enabling the setting +still requires the negotiated materialization capability. + +### Supported Microsoft attachment forms + +This proposal supports: + +1. **Inline images in Personal, groupChat, and channel scopes** + - declared MIME must be `image/*`; + - `contentUrl` remains Gateway-local; + - downloaded bytes must decode as an accepted image; + - images are resized to at most 1200 pixels on the longest side and encoded + as JPEG, except bounded GIF passthrough. + +2. **Personal `file.download.info` attachments** + - conversation type must be Personal; + - the app uses a separate manifest profile with `supportsFiles = true`; + - image extensions use the image pipeline; + - the existing text-extension whitelist is accepted only with strict UTF-8; + - the preauthenticated `downloadUrl` is fetched without the Bot bearer + token. + +3. **Attachment-only events** + - empty text is accepted only when at least one bounded attachment metadata + entry exists; + - the turn proceeds only if materialization produces a usable content block + or a rejected-attachment system block. + +Adaptive Cards, file-info cards sent by the bot, audio, video, PDF, archives, +Office binaries, non-UTF-8 text, and channel/groupChat paperclip files are not +materialized. They become `Attachment::rejected` metadata after trust, rather +than being silently reclassified or downloaded. + +### URL, redirect, and credential policy + +Every attachment request uses a dedicated manual-redirect downloader: + +- HTTPS only in production, port 443, no userinfo or fragment; +- URL query is permitted because Microsoft download URLs can be + preauthenticated, but it is always redacted; +- IP literals, loopback, link-local, private destinations, and arbitrary + operator-provided hosts are rejected; +- inline-image `contentUrl` is bound to the validated Bot Connector public-cloud + origin; +- Personal file URLs and every redirect hop must match the compiled Microsoft + commercial-cloud file-host profile; +- each redirect is resolved and revalidated before the next request; +- the Bot bearer token is attached only to an inline-image request and is never + forwarded across an origin change; +- Personal `downloadUrl` requests never receive the Bot bearer token; and +- error messages contain operation class and rejection category, never URL, + query, token, tenant, conversation, activity, or opaque-reference values. + +The commercial public-cloud profile does not add a user-configurable host +allowlist. New Microsoft cloud profiles or observed official hosts require a +reviewed policy update. + +### Resource limits + +| Control | Limit | +| --- | ---: | +| Metadata entries examined | 10 per activity | +| Opaque references retained | 10 per accepted route | +| Inline image raw download | 10 MiB | +| Personal text file | 512 KiB | +| Aggregate raw download budget | 20 MiB per event | +| GIF passthrough | 5 MiB | +| Materialized response / WS text frame | 8 MiB | +| Redirect hops | 4 | +| Individual HTTP request | existing Teams request timeout | +| Whole materialization batch | 45 seconds | +| Filename after sanitization | 200 Unicode scalars | + +`Content-Length`, when present, is checked before reading but never trusted as +the only bound. Bodies are streamed and stopped before appending bytes past the +remaining per-file or per-event budget. Declared size, actual raw size, output +size, MIME, image decoding, text extension, and UTF-8 are validated +independently. + +Materialization is sequential per event. Existing per-event route capacity, +TTL, dedupe, and one-consumer topology remain in force. + +### Rejection behavior + +A metadata or materialization failure returns `Attachment::rejected` with a +stable category and sanitized detail: + +- `size exceeded`; +- `unsupported format`; +- `download failed`; +- `processing failed`; +- `invalid content`; +- `security rejected`; or +- `configuration error`. + +A rejected attachment has empty `data`, no `path`, and no reusable reference. +Core surfaces the rejected metadata as a system content block only after Allow. +One failed attachment does not discard usable text or another valid attachment. + +Protocol-level failures such as missing capability, unknown reference, expired +route, cross-conversation request, oversized frame, or malformed base64 also +fail closed and never trigger a second download. + +### Rolling compatibility + +| Core | Gateway | Result when configured | +| --- | --- | --- | +| old | new | Text behavior unchanged; unknown references are ignored and no download occurs. | +| new | old or no valid hello | Attachments disabled because materialization capability is absent. | +| new | new with valid capability | Metadata is materialized only after Core Allow. | +| Unified | embedded new adapter | Uses the same reference, limits, and normalized response contract. | + +A new Gateway may publish metadata references before a client hello because +this has no external side effect. Only a new, capability-aware Core can issue +the materialization command. An old Core drops attachment-only metadata and +continues processing text exactly as before. + +## Security and reliability boundaries + +- JWT and tenant validation remain L1 at Gateway. +- Core typed L2 and shared L3 remain authoritative and precede download. +- Attachment URLs and Microsoft credentials stay Gateway-local. +- Opaque references are process-local capabilities, not bearer URLs. +- The internal WebSocket token still authenticates Core-to-Gateway commands. +- Route expiry, restart, replica change, malformed reference, and conversation + mismatch fail closed. +- No Graph, RSC, delegated token, durable attachment queue, replay, or + exactly-once download is introduced. +- Microsoft commercial public cloud remains the only supported cloud profile. + +## Manifest profiles + +The base manifest keeps: + +```json +"supportsFiles": false +``` + +Operators enabling Personal paperclip files use a separate reviewed profile +with `supportsFiles = true`. Inline images do not require this manifest opt-in. +The profile adds no Microsoft Graph or RSC permission. + +## Acceptance criteria + +Automated verification must prove: + +- default off and malformed environment fail closed; +- no download command before structural, L2, and L3 Allow; +- denied text-plus-attachment and attachment-only events cause zero download; +- missing capability and old peers cause zero download; +- opaque reference event/conversation/TTL enforcement; +- URL and every redirect hop are validated without leaking URL or token; +- strict streamed limits, image decode, text extension, and UTF-8; +- unsupported and failed attachments become sanitized rejected metadata; +- attachment-only dispatch after one successful or rejected materialization; +- Standalone and Unified produce equivalent content blocks; +- response and WebSocket frame ceilings; and +- no path crosses a non-shared deployment boundary. + +## Consequences + +### Positive + +- Trust-before-fetch is enforced by one authoritative Core policy. +- Gateway retains Microsoft credentials and sensitive URLs. +- Standalone no longer depends on an accidental shared filesystem. +- Additive capability negotiation keeps rolling upgrades fail closed. +- Unified and Standalone share observable behavior and limits. + +### Negative + +- Materialization adds one internal request/response per attachment. +- Base64 adds bounded memory and approximately one-third encoding overhead. +- Attachment-only turns wait for materialization before showing progress. +- Route-local references are intentionally lost on restart or expiry. +- Strict Microsoft host policy may reject a newly introduced official host until + the profile is reviewed and updated. diff --git a/docs/adr/teams-formatting-and-long-messages.md b/docs/adr/teams-formatting-and-long-messages.md new file mode 100644 index 000000000..34dffd4c2 --- /dev/null +++ b/docs/adr/teams-formatting-and-long-messages.md @@ -0,0 +1,284 @@ +# ADR: Teams Budget-Aware Formatting and Ordered Long Messages + +- **Status:** Proposed +- **Date:** 2026-08-10 +- **Author:** @NeoHsu +- **Related:** + - [Gateway capabilities and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Teams progressive edit response](teams-progressive-response.md) + - [Multi-platform adapters](multi-platform-adapters.md) + +--- + +## Context + +At the initial long-message design baseline, OpenAB advertised Teams with a conservative +4,096-character message limit. Core converted every negotiated non-character +`MessageLimit` into an even smaller character count before calling +`format::split_message`. This was a safe +compatibility default, but it does not model the Teams platform contract: + +- Microsoft documents an approximate 100 KB agent-message limit; +- the size includes message text, image links, mentions, and reactions encoded + as UTF-16, but excludes base64-encoded images; +- Microsoft recommends keeping the message itself within 80 KB for reliable + delivery; and +- an oversized message returns HTTP 413 with `MessageSizeTooBig`. + +The existing capability schema already has additive `characters`, `bytes`, +`utf16_bytes`, and `unlimited` variants. The missing work is exact budget-aware +splitting and a Teams capability value that reflects the documented unit. + +Teams receives OpenAB content as text-only Bot Framework activities with +`textFormat = markdown`. Microsoft documents only a subset of Markdown and +states that text-only messages do not support tables. Rich cards likewise do +not add Markdown-table support. OpenAB already has a table pre-pass whose default `code` mode converts a +Markdown table to an aligned fenced block before message splitting, but at that +baseline the Teams setup guide recommended bypassing the fallback with +`tables = "off"`. + +Long-message delivery also has two different safety levels. Structured Teams +progressive finalization already waits for each required ACK, sends overflow in +order, and stops on the first rejected or unknown write. The ordinary +send-once branch awaits each `Result`, but continues with later chunks after an +earlier failure. That can show a suffix without the missing middle and does not +report a precise partial-delivery boundary. + +## Decision + +### 1. Teams advertises an exact UTF-16 byte budget + +The Teams capability in both Standalone Gateway hello and Unified mode will be: + +```text +MessageLimit::Utf16Bytes { max: 80_000 } +``` + +`80_000` is deliberately decimal, not `80 * 1024`. It follows Microsoft's +recommended 80 KB implementation target rather than treating the approximate +100 KB rejection threshold as a guaranteed text allowance. + +For this limit, Core measures a string as: + +```text +utf16_bytes(text) = 2 * text.encode_utf16().count() +``` + +A BMP scalar therefore costs two bytes and a supplementary-plane scalar costs +four. The limit is not a Unicode-scalar, grapheme, UTF-8-byte, or display-column +count. Table rendering and final display composition happen before the budget +is applied, so their expanded output is included. + +OpenAB's current Teams content activity does not emit outbound mention entities, +cards, or inline base64 images. If those fields are added later, their encoded +size must receive an explicit reserve or a lower text budget before they share +this capability. This proposal does not infer authenticated mentions from plain +text. + +### 2. Core gains one budget-aware splitter + +Keep `format::split_message(text, character_limit)` as the compatibility wrapper +for existing direct callers. Add an internal splitter driven by the negotiated +`MessageLimit` with these unit definitions: + +| Limit | Measurement | +| --- | --- | +| `characters` | Unicode scalar count, preserving current behavior | +| `bytes` | UTF-8 byte length | +| `utf16_bytes` | UTF-16 code units multiplied by two | +| `unlimited` | One unchanged chunk | + +The splitter must preserve the existing structural behavior: + +- prefer newline boundaries, then whitespace boundaries; +- preserve valid UTF-8; +- keep an extended grapheme cluster together whenever that cluster fits in an + empty chunk; +- if one grapheme is larger than the budget, fall back only to Unicode-scalar + boundaries; and +- fail before any final-content write if even one scalar cannot fit the + advertised non-zero budget. + +Fenced code blocks remain balanced independently in every chunk. The complete +opener, including its language tag, is repeated after a split; synthetic close +and reopen markers are included in the selected unit's budget. A zero limit or +an otherwise unsplittable non-empty value is an error, not an infinite loop, +truncation, invalid UTF-8, or oversized final-content write. A streaming turn +may already own its acknowledged placeholder; that existing activity follows +the [progressive-response failure lifecycle](teams-progressive-response.md) and +is never converted into a blind fresh send. + +The 1.5-second cosmetic Teams edit path may retain its existing conservative +character preview. Authoritative final PUT/POST chunks use the exact negotiated +budget. This keeps intermediate writes safely below the limit without widening +this proposal into a streaming-preview redesign. + +### 3. Markdown remains text-only and tables use the existing fallback + +Teams outbound activities continue to set `textFormat = markdown`. This +proposal does not create Adaptive Cards or rich cards. + +OpenAB preserves the agent's Markdown source except for the existing configured +table conversion: + +- `tables = "code"` remains the default and recommended Teams setting; +- `tables = "bullets"` remains an accessibility-oriented fallback; +- `tables = "off"` remains an explicit operator bypass, but raw Markdown tables + are not claimed to render as tables in Teams; and +- Teams continues to report `renders_native_tables = false` in both deployment + modes. + +The Teams setup documentation will stop recommending `tables = "off"`. +Mention-looking text and Markdown links are not rewritten, dropped, or copied +into later chunks; an individual token longer than the whole budget may still +be split at a valid Unicode boundary. Existing Discord mention-footer +propagation is unchanged and is not applied to Teams. Headings, lists, +strikethrough, and other Markdown remain subject to Microsoft's published +desktop/iOS/Android support differences. This proposal does not rewrite them +into a new rich-message schema or claim identical presentation across clients. + +### 4. Required-ACK chunk delivery is sequential and terminal + +Core builds the complete final chunk list before the first platform write. When +the resolved capability requires send ACKs, every POST is delivered through an +outcome-preserving method and the sequence obeys: + +1. Send chunk `N` only after chunk `N - 1` is `Delivered`. +2. A delivered POST requires a non-empty real activity ID. +3. Stop at the first `Rejected` or `Unknown`; never skip it and send a suffix. +4. Never retry a rejected or unknown POST in Core. In particular, the existing + Teams rule that records a 429 `Retry-After` without retrying POST remains + unchanged. +5. An `Unknown` terminal outcome suppresses retry, fresh-send, cleanup, and the + router's warning activity because the selected chunk may have committed. +6. A `Rejected` terminal outcome may use the existing single warning route, + because rejection is explicit, but it does not resume the chunk sequence. + +The delivery result records, without message content or activity IDs: + +- total chunk count; +- number known delivered; +- zero-based failed chunk index; and +- terminal outcome kind and sanitized code. + +If at least one earlier chunk was delivered, the error is explicitly classified +as partial delivery. Already-delivered activities are not deleted or edited in +an attempted rollback. Processing status and reaction progress become failed, +and status is cleared only after every final chunk is delivered. + +This rule applies to ordinary send-once, explicit-reply-first, progressive +overflow, and rejected-placeholder recovery paths when required send ACK is +available. Existing progressive helpers already implement the ordering and +ambiguity rules; this proposal unifies the send-once branch with that behavior +rather than adding a second Teams-specific retry loop. + +### 5. Rolling compatibility remains fail closed + +| Core | Gateway | Result | +| --- | --- | --- | +| old Core without hello | new Gateway | Legacy behavior; Gateway accepts the first reply and sends no unsolicited control requirement. | +| capability-aware old Core | new Gateway | Decodes the existing `utf16_bytes` variant and uses its conservative quarter-size character bound; still safe, but not exact. | +| new Core | old Gateway or no valid hello | Existing `characters = 4096` legacy limit and legacy delivery semantics. | +| new Core | new Gateway | Exact 80,000 UTF-16-byte split plus required-ACK ordered delivery. | +| new Unified | embedded Teams adapter | Same exact budget and delivery semantics as new↔new Standalone. | + +A valid hello remains authoritative. Missing Teams capabilities in a valid hello +do not fall back optimistically. Protocol version stays v1 because the +`utf16_bytes` wire variant was already additive before this proposal. + +### 6. Observability is content-free + +One finalization summary may record platform, total chunks, delivered chunks, +failed index, outcome kind, and sanitized error code. It must not log message +content, activity IDs, conversation IDs, request IDs, URLs, tokens, or serialized +Connector bodies. + +A Microsoft HTTP 413 remains `Rejected / message_too_large`. It is evidence that +the conservative target was insufficient for that activity shape, not a reason +to retry the same body or silently change the limit during the turn. + +## Security and reliability boundaries + +- Trust admission, route ownership, tenant/conversation checks, and write + serialization are unchanged. +- No Graph, RSC, delegated token, Adaptive Card permission, or manifest + permission is added. +- POST `Unknown` is never retried or converted into a fresh warning activity. +- Partial delivery is not transactional; delivered prefix activities may remain + when a later chunk fails. +- The feature does not claim exactly-once delivery, crash replay, durable + ownership, or multi-consumer work distribution. +- Microsoft commercial public cloud remains the only supported Teams profile. + +## Acceptance criteria + +Automated verification must cover: + +- exact character, UTF-8-byte, UTF-16-byte, and unlimited measurements; +- BMP, CJK, supplementary emoji, combining marks, ZWJ sequences, and mixed text; +- every emitted chunk fitting its own budget, including synthetic code-fence + close/reopen markers and language tags; +- newline/whitespace preference, order preservation, no truncation, valid UTF-8, + and explicit unsplittable-budget failure; +- Teams Standalone hello and Unified parity advertising + `utf16_bytes = 80_000`; +- new Core→old Gateway/no-hello 4,096-character fallback and decoding by a + capability-aware old Core; +- table conversion before splitting, Teams native-table=false behavior, and the + explicit `off` bypass; +- send-once, explicit reply, progressive overflow, and recovery chunk order; +- stop-on-first `Rejected` and stop-on-first `Unknown`, with no later chunk; +- delivered/total/failed-index partial-delivery classification; +- no warning, delete, retry, or fresh send after an unknown POST; +- HTTP 413 mapping to `message_too_large`; and +- no regressions in existing character-based Discord/Slack splitting or Teams + progressive ambiguity tests. + +## Consequences + +### Positive + +- Teams uses the platform's documented unit instead of a fictional character + limit. +- ASCII and BMP-heavy long replies require fewer activities while emoji-heavy + replies remain correctly bounded. +- Code fences and table fallbacks are budgeted after rendering. +- Users never receive a later suffix after a known missing middle chunk. +- Rolling upgrades remain safe without a protocol bump. + +### Negative + +- The generic splitter becomes more complex and must carry unit-aware tests. +- Larger individual activities may take longer to render or edit even though + they remain within Microsoft's recommendation. +- Partial delivery cannot be made atomic with Bot Connector primitives. +- Cosmetic streaming previews remain more conservative than final messages. + +## Alternatives rejected + +1. **Keep 4,096 characters permanently.** Safe but knowingly misrepresents the + platform, produces unnecessary activities, and leaves the UTF-16 capability + unused. +2. **Advertise 100 KB.** Rejected because Microsoft labels it approximate and + recommends an 80 KB implementation target. +3. **Use 80,000 Unicode scalars.** Rejected because supplementary characters + consume twice the UTF-16 bytes of BMP characters. +4. **Use UTF-8 bytes.** Rejected because it is not the unit Microsoft documents + for this limit. +5. **Split inside Gateway.** Rejected because Core owns final formatting, + directive handling, ordered delivery health, and Unified/Standalone parity; + Gateway-side splitting would hide per-chunk outcomes from Core. +6. **Continue after a rejected middle chunk.** Rejected because a suffix without + its missing middle is a corrupt user view. +7. **Retry an unknown POST.** Rejected because it can duplicate an activity that + Microsoft already committed. +8. **Use Adaptive Cards for every answer.** Rejected because cards have different + formatting semantics, do not solve Markdown tables, and belong to a later + explicitly reviewed feature. + +## References + +- [Microsoft: Format your agent messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) +- [Microsoft: Update and delete messages sent from agent](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) +- [OpenAB platform schema: Teams](../platforms/schema/teams.toml) diff --git a/docs/adr/teams-processing-indicator.md b/docs/adr/teams-processing-indicator.md new file mode 100644 index 000000000..ba8bf5c8b --- /dev/null +++ b/docs/adr/teams-processing-indicator.md @@ -0,0 +1,201 @@ +# ADR: Teams Processing-Message Indicator + +- **Status:** Proposed +- **Date:** 2026-08-07 +- **Author:** @NeoHsu +- **Related:** + - [Gateway capabilities and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + - [Teams public-preview message reactions](teams-message-reactions-preview.md) + - [Teams progressive edit response](teams-progressive-response.md) + - [Turn-boundary message batching](turn-boundary-batching.md) + +--- + +## Context + +Teams has no generally available native status API equivalent to Slack +`assistant.threads.setStatus`. Bot Connector typing activities are transient and +not part of a generally available processing-status guarantee. The implemented +Teams reaction backend is Microsoft public preview, explicitly opt-in, and therefore cannot be the +default processing-indicator contract. + +This proposal needs a visible, Graph-free processing lifecycle without enabling +content streaming or weakening the existing route and bot-ownership checks. It must +also preserve the batching contract: every event may keep its permanent queued +receipt when reaction preview is enabled, while only the final event in a batch +anchors transient progress. + +## Decision + +### Explicit opt-in + +Add one first-class Teams setting: + +```toml +[teams] +processing_indicator = "off" # off | message +``` + +The environment fallback is `TEAMS_PROCESSING_INDICATOR`. Missing or malformed +environment values resolve to `off`; malformed TOML enum values fail config +parsing. The default is `off`, preserving existing deployments. + +`processing_indicator = "message"` selects a processing **message** lifecycle. +It does not enable streaming, reactions, typing activities, Graph, RSC, or any +new manifest permission. + +### Capability selection and rolling upgrades + +Core gains an internal `StatusBackend::Message` value. The processing message +uses only the existing commandless send plus bot-owned edit/delete operations; +no new Gateway command or protocol-version bump is introduced. + +Standalone Core selects the message backend only after a valid Gateway hello +advertises all required primitives for Teams: + +- required send, edit, and delete acknowledgements; +- `supports_target_message_id`; +- bot-owned edit and delete support. + +Before a valid hello, or when any required primitive is absent, configured +message status fails closed to `StatusBackend::None`. Final answer delivery is +unaffected. Unified mode selects the backend only when the in-process Teams +adapter provides the same primitives. + +Reaction availability and progress-backend choice are independent. Add an +additive `supports_reactions` capability bit. A peer that advertises the older +`status_backend = reactions` shape is normalized to reaction support for +rolling compatibility; old Core ignores the new bit. + +When both opt-ins are active: + +- `supports_reactions = true` keeps permanent queued receipts on every event in + the batch; and +- `status_backend = message` drives transient thinking/tool progress only on the + final event. + +When `processing_indicator = "off"`, the existing reaction-preview behavior is +unchanged. + +### One turn-local status activity + +Each admitted dispatch turn creates one turn-local processing controller. Its +identity is the controller instance plus the real Bot Connector activity ID +returned by the initial status send. The controller is constructed from the +final event's `ChannelRef`, including its authenticated `origin_event_id`, so +successive turns never share a status target. + +For a batched turn, Core creates exactly one controller from +`batch.last().trigger_msg`. It never creates one status message per queued +receipt. + +The controller emits at most one new status activity: + +| Transition | Visible text | Transport | +| --- | --- | --- | +| start / thinking | `⏳ Processing…` | commandless POST | +| tool start | `🛠️ Using …` | PUT same activity | +| tool done / thinking | `⏳ Processing…` | PUT same activity | +| successful terminal | `✅ Completed` | PUT same activity | +| agent error | `❌ Failed` | PUT same activity | +| hard timeout | `⏱️ Timed out` | PUT same activity | +| final delivery failure | `❌ Delivery failed` | PUT same activity | +| clear after delivered final content | — | DELETE same activity | + +Tool labels are broker-generated metadata, not user prompt text. Normalize line +breaks and backticks and cap the rendered label before sending it to Teams. +Duplicate states are no-ops. + +### Terminal-before-final ordering + +Status and content streaming remain separate lifecycles: + +1. mark the processing activity terminal before the first final-content write; +2. deliver every final-content chunk through the normal send path; +3. after complete delivery, delete the terminal status activity; +4. if final delivery is incomplete, change the status to + `❌ Delivery failed` and leave it visible. + +This ordering prevents a failed delete from leaving an apparently active +`Processing…` message. If terminal edit succeeds but delete is rejected or +unknown, the recognizable terminal text remains. Ambiguous POST, PUT, or DELETE +outcomes never trigger a fresh status send or blind retry. + +Status writes are cosmetic: a status failure is logged and must not suppress, +duplicate, or fresh-send the final answer. + +### Receipt and progress separation + +`docs/adr/turn-boundary-batching.md` §6.7 remains authoritative: + +- each batch event receives its permanent queued receipt sequentially when the + adapter explicitly supports reactions and global reactions are enabled; +- the turn-local processing controller anchors only on the final event; and +- the controller never removes queued receipts. + +Teams with reaction preview disabled has no native queued-receipt side effect; +it still creates at most one opt-in processing message for the turn. + +## Failure boundaries + +- Initial status POST `Rejected` or `Unknown`: disable message status for that + turn; do not fresh-send another status. +- Status PUT `Rejected` or `Unknown`: keep the known activity handle for final + terminal/delete cleanup; do not blind retry. +- Terminal PUT succeeds but DELETE fails: leave terminal text visible. +- Final answer delivery fails: report the existing delivery error and leave a + delivery-failed terminal status when possible. +- Gateway/Core restart or ownership/route expiry may prevent terminal cleanup. + Status state is intentionally process-local, matching existing route and + ownership boundaries; this proposal does not add crash replay or durable + cleanup. + +## Security boundaries + +- L1 tenant/JWT, typed L2 scope, structured mention, and L3 identity gates run + before status creation. +- The initial POST resolves only through the authenticated origin event route. +- PUT/DELETE target only the returned bot-owned activity ID and reuse existing + tenant/conversation ownership validation and write serialization. +- Status text contains no prompt, service URL, token, tenant ID, conversation + ID, activity ID, or attachment URL. +- No Graph, RSC, delegated token, proactive registry, or new permission is + introduced. + +## Acceptance criteria + +Automated tests must cover: + +- config/TOML/environment resolution and fail-closed defaults; +- additive `supports_reactions` old/new capability decoding; +- configured message status remaining disabled before hello or when one + required primitive is absent; +- Standalone and Unified selecting identical Teams backend semantics; +- one POST followed only by PUTs against its real returned activity ID; +- thinking, tool, success, error, timeout, delivery-failure, and clear order; +- terminal PUT before final content and DELETE only after complete delivery; +- POST/PUT/DELETE rejection or unknown outcomes without fresh-send fallback; +- one status activity per batch, anchored to the final event; +- permanent queued receipts surviving when reaction preview and processing + message are both enabled; and +- no status side effect before trust gates pass. + +## Consequences + +### Positive + +- Teams gains a visible processing lifecycle without Graph or preview APIs. +- One real activity ID gives deterministic, turn-local mutation ownership. +- Processing messages and queued reaction receipts can coexist without + conflating their lifecycles. +- Existing deployments remain unchanged by default. + +### Negative + +- Each enabled turn adds one POST, several bounded PUTs, and normally one + DELETE to the conversation write stream. +- A process restart can orphan a processing message because status state is + process-local and not durable. +- Tool-heavy turns need transition coalescing to avoid cosmetic write pressure. diff --git a/docs/adr/teams-progressive-response.md b/docs/adr/teams-progressive-response.md new file mode 100644 index 000000000..5b93d1245 --- /dev/null +++ b/docs/adr/teams-progressive-response.md @@ -0,0 +1,220 @@ +# ADR: Teams Progressive Edit Response + +- **Status:** Proposed +- **Date:** 2026-08-08 +- **Author:** @NeoHsu +- **Related:** + - [Gateway capabilities and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Teams bot-owned message mutations](teams-owned-message-mutations.md) + - [Teams processing-message indicator](teams-processing-indicator.md) + - [Turn-boundary message batching](turn-boundary-batching.md) + +--- + +## Context + +OpenAB already has a platform-neutral post-and-edit streaming path. Teams could +not safely use it before +[real-send acknowledgement](teams-real-send-acknowledgement.md) and +[bot-owned mutations](teams-owned-message-mutations.md), because a placeholder +POST did not return a real Bot Connector activity ID and later PUT/DELETE operations had no +operation-specific acknowledgement or bot-ownership boundary. + +The linked real-send and mutation decisions supply those primitives. This +proposal may opt Teams into progressive content, but it must not reintroduce synthetic IDs, fixed best-effort waits, blind retries, or a +fresh final message after an ambiguous write. It must also preserve the +capability separation rule: the [processing message](teams-processing-indicator.md) and content placeholder are +independent activities and lifecycles. + +## Decision + +### Explicit, default-off setting + +Add one first-class Teams setting: + +```toml +[teams] +streaming = false +``` + +The environment fallback is `TEAMS_STREAMING`. Missing or malformed environment +values resolve to `false`; malformed TOML values fail config parsing. The +existing generic `[gateway].streaming` setting must not implicitly enable Teams. +Existing deployments therefore remain send-once. + +Streaming does not enable reactions, processing messages, attachments, Graph, +RSC, delegated tokens, or new manifest permissions. + +### Capability selection and rolling upgrades + +Teams progressive response requires all of these primitives: + +- a valid Gateway hello; +- required send, edit, and delete acknowledgements; +- `supports_target_message_id`; +- bot-owned edit and delete support; and +- `show_streaming_placeholder = true`. + +When configured and all primitives are present, new Core selects internal +`StreamingMode::Edit`. Otherwise it fails closed to `StreamingMode::Disabled`. +Unified selects the same mode only when its in-process Teams adapter provides +the same primitives. + +The Gateway hello continues to advertise Teams `streaming_mode = disabled` so +an old Core with an unrelated generic gateway streaming switch cannot begin +streaming merely because Gateway was upgraded first. New Core derives the +opt-in mode from the explicit Teams setting plus the existing primitive flags; +no protocol version or new command is required. + +Compatibility behavior is: + +| Core | Gateway | Configured Teams result | +| --- | --- | --- | +| old | new | Send-once; new Gateway does not advertise selected Teams streaming. | +| new | old without valid hello | Send-once, fail closed. | +| new | valid hello with every required primitive | Edit streaming may be selected. | +| new | hello missing any primitive | Send-once, fail closed. | +| Unified | embedded Teams adapter | Edit streaming only under the explicit Teams opt-in. | + +### One real placeholder per turn + +After L1/L2/L3 admission and successful ACP prompt start, Core creates at most +one content placeholder for the turn through the authenticated event route. +Required send ACK must return a non-empty real activity ID. That ID is the only +placeholder target the turn may edit or delete. + +A batched turn still runs one ACP turn and therefore creates one placeholder, +anchored to the final event's authenticated `origin_event_id`. Concurrent or +successive turns never share placeholder state. Multi-bot participation keeps +the existing per-turn streaming disable and falls back to send-once. + +Placeholder POST outcomes are handled as follows: + +- `Delivered` with a real ID: begin progressive edits; +- `Rejected`: no activity was created, so complete the ACP turn and safely use + the normal send-once final path; +- `Unknown`, required-ACK timeout, or closed ACK channel: do not create another + placeholder or fresh-send the final answer, because the first POST may have + committed without returning its ID. + +### Coalesced cosmetic edits + +The existing edit loop remains the common implementation: + +- publish only changed display content; +- coalesce at a minimum 1.5-second interval; +- wait for the negotiated edit ACK budget, never the legacy Feishu 800 ms + observation window; +- let Gateway perform only its existing explicit short `429 Retry-After` + bounded retry; +- never retry the same content after a rejected or unknown PUT; a later edit is + allowed only when newer content supersedes it; and +- stop cosmetic edits after three consecutive failed changed-content writes. + +All Teams POST, PUT, DELETE, and reaction writes continue to share the existing +per-conversation write shard. Core aborts and joins the cosmetic edit task before +the authoritative final write so a stale edit cannot overtake finalization. + +### Outcome-aware finalization + +The first final chunk is authoritative. Core must retain the structured outcome +instead of flattening it to an undifferentiated error. + +| Final operation | Required behavior | +| --- | --- | +| Placeholder PUT `Delivered` | Send overflow chunks sequentially. | +| Placeholder PUT `Rejected` | Attempt one placeholder DELETE, then use one fresh POST recovery unless DELETE is `Unknown`. | +| Placeholder PUT `Unknown` | Do not DELETE, retry PUT, or fresh-send. Mark delivery ambiguous. | +| Recovery DELETE `Delivered` | Fresh-send the first final chunk once. | +| Recovery DELETE `Rejected` | Fresh-send once; the rejected delete may leave partial placeholder overlap, but no full final activity exists. | +| Recovery DELETE `Unknown` | Do not fresh-send because deletion may have committed. | +| Recovery POST `Rejected` or `Unknown` | Do not retry. | + +An explicit `[[reply_to:...]]` directive remains a deliberate new-message path: +Core sends the quoted final activity first and deletes the placeholder only +after that send is `Delivered`. An unknown quoted POST is not retried and does +not trigger placeholder deletion. + +Overflow chunks are sent in order only after the first final chunk is known to +be delivered. Core stops at the first rejected or unknown overflow POST; every +chunk contributes to delivery health. It never skips a failed chunk and sends a +later one. + +An ambiguous progressive write returns a delivery error that suppresses the +router's usual fresh warning message. Reactions or a separate processing status +may still show failure, but Core must not turn ambiguity into another Teams +activity. + +### Processing-status coexistence + +`processing_indicator = "message"` and `streaming = true` may coexist. They +produce two distinct activities: + +1. the processing status follows its thinking/tool/terminal lifecycle; and +2. the streaming placeholder contains progressively rendered answer content. + +Core marks the processing activity terminal before the authoritative final +content write and clears it only after every final chunk is delivered. A final +delivery failure leaves or updates the separate status to a recognizable +failure state when possible. Neither controller edits or deletes the other's +activity ID. + +Reaction preview remains independent. When enabled, queued `👀` receipts stay +permanent while content progress uses the placeholder. + +## Security and reliability boundaries + +- Trust gates run before placeholder creation. +- Placeholder POST uses only the authenticated process-local event route. +- PUT/DELETE reuse bot-owned activity validation, tenant/conversation matching, + TTL bounds, and write serialization. +- No placeholder ID survives restart, route expiry, or replica changes. +- `Unknown` is never reclassified as rejection and never causes blind retry or + fresh-send. +- This proposal does not claim exactly-once delivery, crash cleanup, replay, + durable ownership, or multi-consumer work distribution. +- Microsoft commercial public cloud remains the only supported cloud profile. + +## Acceptance criteria + +Automated verification must cover: + +- config, environment fallback, malformed-value fail-closed behavior, and the + default-off invariant; +- Teams not inheriting generic Gateway or Telegram streaming settings; +- valid-hello and every-required-primitive capability gating in Standalone; +- identical Unified capability selection; +- one placeholder POST returning and reusing one real activity ID; +- coalescing, changed-content-only writes, three-failure cutoff, and edit-loop + abort/join before finalization; +- required ACK timeout rather than the legacy 800 ms path; +- final PUT `Delivered`, `Rejected`, and `Unknown` branches; +- recovery DELETE `Delivered`, `Rejected`, and `Unknown` branches; +- no fresh-send or warning activity after ambiguous POST, PUT, or DELETE; +- explicit-reply finalization; +- ordered overflow delivery stopping on first failure; +- processing-message and permanent receipt coexistence; and +- no placeholder side effect before trust admission. + +## Consequences + +### Positive + +- Teams gains progressive answer content using already-authenticated Connector + writes and real activity IDs. +- Explicit outcome handling preserves duplicate safety during transport + ambiguity. +- Rolling upgrades cannot accidentally enable streaming in an old Core. +- Status, receipts, and content progress remain independently configurable. + +### Negative + +- Enabled turns add repeated acknowledged PUTs and can increase Connector write + pressure. +- An ambiguous write may leave a partial placeholder and intentionally suppress + a fresh final answer. +- Recovery after an explicitly rejected DELETE may show partial placeholder + overlap beside the complete fresh answer. +- Process restart can orphan a placeholder because state remains intentionally + non-durable. From c31c0b15366f833783dfe7606519d28973d88f93 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:51:47 +0800 Subject: [PATCH 03/16] docs(teams): propose commands and proactive delivery --- docs/adr/custom-gateway.md | 40 +- docs/adr/teams-operator-cron-delivery.md | 378 ++++++++++++++++++ docs/adr/teams-text-command-parity.md | 323 +++++++++++++++ ...rusted-persistent-conversation-registry.md | 375 +++++++++++++++++ 4 files changed, 1102 insertions(+), 14 deletions(-) create mode 100644 docs/adr/teams-operator-cron-delivery.md create mode 100644 docs/adr/teams-text-command-parity.md create mode 100644 docs/adr/teams-trusted-persistent-conversation-registry.md diff --git a/docs/adr/custom-gateway.md b/docs/adr/custom-gateway.md index 71d299cf7..43a1d70c1 100644 --- a/docs/adr/custom-gateway.md +++ b/docs/adr/custom-gateway.md @@ -6,12 +6,14 @@ - **Supersedes:** Sections of [ADR: LINE Adapter](./line-adapter.md) (v2 Target Architecture) - **Superseded by:** [ADR: Separate Binaries with Opt-In Unified Build](./unified-binary.md) -> **⚠️ This ADR is partially superseded by the unified binary architecture.** -> OpenAB now supports a single unified binary mode for simplified deployment -> (see [ADR: Separate Binaries with Opt-In Unified Build](./unified-binary.md)). -> While the unified binary is the recommended path for standard setups, the -> standalone gateway architecture remains fully supported for deployments -> requiring strict outbound-only network isolation for the OpenAB core. +> **Standing: historical reference only.** This ADR records the original +> standalone-gateway proposal and is not operative implementation instruction. +> Unified mode is the standard deployment path; standalone Gateway remains +> supported when Core must stay outbound-only. For operative protocol and delivery +> requirements, use [Separate Binaries with Opt-In Unified Build](./unified-binary.md) +> and [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md). +> Every requirement, rollout step, and compliance statement below describes this +> superseded proposal unless a non-superseded ADR explicitly adopts it. --- @@ -22,6 +24,7 @@ As an OpenAB operator, I want to connect webhook-based platforms (LINE, Telegram As a platform integrator, I want to write a plugin/adapter for my platform and register it with the gateway, so that any webhook source can drive an OAB agent session without upstream code changes. Requirements: + - OAB core must remain outbound-only — no inbound ports, no TLS, no K8s Service - The gateway is a separate, independently deployable service - Adding a new webhook platform requires only a gateway plugin, zero OAB changes @@ -101,7 +104,7 @@ Outbound (OAB → platform): --- -## 3. Internal Event Schema +## 3. Historical Internal Event Schema The contract between the gateway and OAB. All platform-specific details are normalized away before crossing this boundary. @@ -136,6 +139,7 @@ The contract between the gateway and OAB. All platform-specific details are norm ``` Key fields in the base schema: + - **`channel.thread_id`**: thread identifier for platforms that support threads (Discord thread ID, Slack `thread_ts`). `null` for platforms without threads (LINE). OAB uses this for session key construction — without it, per-thread session isolation cannot work through the gateway. - **`mentions`**: array of mentioned entity IDs (users, bots). Required for @mention gating — the gateway adapter parses platform-specific mention formats and normalizes them here. Without it, OAB cannot determine whether the bot was mentioned, breaking the primary mitigation for LINE group chat noise and Discord/Slack trigger logic. @@ -157,7 +161,10 @@ Key fields in the base schema: ``` Key fields in the outbound reply: -- **`reply_to`**: the `event_id` of the inbound `GatewayEvent` that triggered this reply. The gateway can use this for reply correlation — e.g., looking up a cached LINE reply token to prefer the free Reply API over the quota-consuming Push API. Empty string if the reply is not associated with a specific inbound event (e.g., cron-triggered messages). + +- **`reply_to`**: the `event_id` of the inbound `GatewayEvent` that triggered this reply. The gateway can use this for reply correlation — e.g., looking up a cached LINE reply token to prefer the free Reply API over the quota-consuming Push API. Empty string if the reply is not associated with a specific inbound event (e.g., cron-triggered messages). Legacy command peers may still overload it with a platform target. +- **`quote_message_id`**: optional platform message selected for visual reply/quote behavior; it is distinct from origin correlation. +- **`target_message_id`**: additive optional target for edit/delete/reaction commands. Core uses it only when the negotiated capability advertises support; otherwise it copies the target into legacy `reply_to`. ### Design Principles for the Schema @@ -178,9 +185,11 @@ The following fields/concepts are known to be needed but are not fully defined i | `reply_context` | Reply token, quote target, original message reference — not all platforms only need `channel.id` to deliver a reply | | `tenant` / `gateway_instance` | Multi-tenancy routing if a shared gateway serves multiple OAB instances | +The capability and delivery-result portion is resolved by [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md). Teams process-local route and duplicate-suppression behavior is resolved by [Teams Ephemeral Ingress Route and Duplicate Suppression](teams-ephemeral-ingress-state.md), its event correlation plus real send ACK by [Teams Real Send Acknowledgement and Reply Correlation](teams-real-send-acknowledgement.md), and its explicit command target plus ownership enforcement by [Teams Bot-Owned Message Mutations](teams-owned-message-mutations.md). The other rows remain deferred. + --- -## 4. Gateway Adapter Interface +## 4. Historical Gateway Adapter Interface Each platform adapter implements a common interface: @@ -305,7 +314,7 @@ GitHub shows the gateway handling a non-chat event. The adapter maps repo → ch --- -## 7. Open Design Questions +## 7. Historical Open Design Questions | Question | Options | Impact | |---|---|---| @@ -317,11 +326,11 @@ GitHub shows the gateway handling a non-chat event. The adapter maps repo → ch --- -## 8. Rollout Plan +## 8. Historical Rollout Plan | Phase | Scope | Deliverable | |---|---|---| -| **v1 (now)** | LINE adapter inside OAB | PR #521 — unblocks LINE users | +| **v1 (2026-04-22 baseline)** | LINE adapter inside OAB | PR #521 — unblocks LINE users | | **v2** | Standalone gateway + OAB generic gateway adapter + LINE migrated out | Gateway service, LINE adapter, internal event schema, OAB connects via WebSocket | | **v3** | Multi-platform gateway | Telegram, GitHub, custom adapters | | **v4** | Plugin / distribution model | Third-party adapters without forking gateway | @@ -386,10 +395,13 @@ The gateway holding all platform credentials is the correct architectural choice --- -## Compliance +## Historical Compliance Proposal + +These clauses governed the superseded proposal; non-superseded ADRs named in the +standing notice above take precedence. 1. **OAB outbound-only**: after adoption of the custom gateway architecture, new platform integrations must not add inbound platform-traffic handling to OAB core unless explicitly approved by a superseding ADR. -2. **Event schema stability**: the `openab.gateway.event.v1` schema is currently a draft envelope for v2 development. The protocol spec must finalize all required fields (including deferred concerns in Section 3) before the schema is declared stable. Once declared stable, breaking changes require a version bump (`v2`) and a migration path. +2. **Event schema stability**: at ADR version 0.2 (2026-06-29), `openab.gateway.event.v1` was a draft envelope for v2 development. Current wire stability is defined by the implementation and [Gateway Capabilities and Delivery Semantics](gateway-capabilities-and-delivery-semantics.md); this historical clause has no independent authority. 3. **Credential isolation**: platform credentials (tokens, secrets) must reside in the gateway, not in OAB. OAB must not hold or access platform-specific authentication material. 4. **Adapter interface compliance**: all gateway adapters must implement `validate`, `parse`, `send`, and `health`. Adapters that skip signature validation must be explicitly flagged as insecure. 5. **Webhook correctness**: all adapters must validate signatures against exact raw request body bytes, per the constraints defined in [ADR: LINE Adapter](./line-adapter.md) Compliance #1. diff --git a/docs/adr/teams-operator-cron-delivery.md b/docs/adr/teams-operator-cron-delivery.md new file mode 100644 index 000000000..7ef9e4f90 --- /dev/null +++ b/docs/adr/teams-operator-cron-delivery.md @@ -0,0 +1,378 @@ +# ADR: Teams Operator Cron over Trusted Persistent Conversations + +- **Status:** Proposed +- **Date:** 2026-08-20 +- **Author:** @NeoHsu +- **Related:** + - [Teams trusted persistent conversation registry](teams-trusted-persistent-conversation-registry.md) + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Basic cron scheduler](basic-cronjob.md) + +--- + +## Context + +OpenAB's scheduler can dispatch operator-configured prompts through Discord, +Slack, Telegram, Google Chat, and LINE WORKS adapters. It first posts a visible +cron trigger, then starts or reuses the ACP session only after that trigger send +succeeds. Teams is intentionally absent from `VALID_PLATFORMS` and from the cron +adapter map because ordinary Teams sends require a short-lived inbound event +route. + +The [persistent-registry proposal](teams-trusted-persistent-conversation-registry.md) +adds an explicit-path, default-off Gateway-local registry. A route enters that +registry only after Bot Framework authentication plus Core structural, +typed L2, and L3 admission. The registry retains the complete +app/tenant/Bot-Framework-channel/conversation identity and validated +`serviceUrl`, and exposes only active, non-expired records for +operator-scheduled delivery. Core and ACP never receive the stored reference or `serviceUrl`. + +This proposal connects the existing operator scheduler to that registry without +turning agent-writable usercron into cross-conversation authority. It does not add a new +scheduler, create Teams conversations, install an app, or implement `/remind`. + +## Constraints + +- Existing deployments and non-Teams cron behavior remain unchanged by default. +- A Teams cron job may target only an exact active, non-expired registry record. +- The complete registry identity must be enforced; conversation ID alone is not + a registry key. +- `serviceUrl`, the stored record, and app credentials remain Gateway-local and + never enter Core config, the Gateway wire, ACP input, responses, or logs. +- Config-defined baseline cron is operator authority. The hot-reloaded + `cronjob.toml` is explicitly agent-writable in current OpenAB documentation and + is not equivalent authority. +- Standalone and Unified must apply the same lookup, send, delivery-outcome, and + registry-reconciliation rules. +- Every accepted content POST has a real Bot Framework activity ID. Ambiguous + writes are `Unknown` and are never retried blindly. +- The baseline remains one Gateway writer and one active Standalone Core consumer. + +## Current-System Findings + +### Scheduler authority and ordering + +`[[cron.jobs]]` is immutable process config and is validated at startup. The +scheduler sends the visible cron trigger before it calls `AdapterRouter`, so a +failed destination consumes no ACP session or agent work. The scheduler also +prevents overlap for one job and treats the next matching schedule as a new +execution rather than retrying a failed tick. + +The separate `cronjob.toml` overlay is disabled by default but may be +hot-reloaded and written by the agent. It can also execute an operator-approved +local `disable_on_success` command. It therefore cannot safely select an +arbitrary trusted Teams record without a future user/scope binding design. + +### Standalone adapter lifetime + +At the design baseline, Unified mode retained one shared adapter for cron. +Standalone constructed a new `GatewayAdapter` inside each WebSocket connection +loop and did not expose it to the scheduler. This proposal needs a stable +reconnect-aware proxy: the +proxy delegates to the current negotiated connection, clears it on disconnect, +and never creates a second Gateway WebSocket consumer. + +### Microsoft Teams proactive delivery + +Microsoft documents scheduled messages as proactive messages. The app must +already be installed in the destination; a stored conversation ID or +conversation reference is required, and the incoming `serviceUrl` should be +retained instead of hardcoding an endpoint. Proactive messaging cannot create a +new group chat or a new Team channel. A blocked or uninstalled app can return +HTTP 403 with `MessageWritesBlocked`; Microsoft also documents +`BotNotInConversationRoster` when the bot is no longer a member of the +conversation. + +### Reviewed prior art + +OpenClaw revision +[`4994f7bacf308269a0770b4a912c44a746cccec7`](https://github.com/openclaw/openclaw/tree/4994f7bacf308269a0770b4a912c44a746cccec7) +resolves an outbound target through its conversation store, validates the +stored service endpoint/cloud boundary, reconstructs the SDK reference inside +the Teams plugin, and sends directly to the stored conversation. OpenAB adopts +the Gateway-local resolution and endpoint validation, but requires the complete +registry key and active state rather than a conversation-ID-only lookup. + +Hermes Agent revision +[`00e5a361b60f621ae2246dffdd0a0252895d8493`](https://github.com/NousResearch/hermes-agent/tree/00e5a361b60f621ae2246dffdd0a0252895d8493) +uses an operator-configured standalone Teams destination and validates its +service host and conversation identifier. OpenAB adopts explicit operator +selection but not a static service URL in Core or config; the trusted registry +remains the only source of the endpoint. + +## Decision + +### 1. Authority boundary + +Only baseline `[[cron.jobs]]` entries may set `platform = "teams"` under this proposal. +These entries are operator-controlled process configuration and intentionally +bypass inbound user L2/L3 checks at execution time, as existing operator cron +does. Their destination is still constrained to a record that previously +passed those checks and remains active. + +The usercron loader rejects every Teams entry with a bounded, identifier-free +warning. It does not partially execute the job, resolve a registry record, send +a message, or start ACP. `/remind`, agent-created recurring schedules, and any +user-facing target selector remain separate follow-ups that must bind the +initiating user and scope and revalidate them at execution. + +### 2. Additive target configuration + +A Teams baseline job keeps `channel` as the stored Teams conversation ID and +adds one required field: + +```toml +[[cron.jobs]] +enabled = true +schedule = "0 9 * * 1-5" +platform = "teams" +channel = "" +teams_tenant_id = "" +message = "summarize yesterday's merged work" +sender_name = "DailyOps" +timezone = "Asia/Taipei" +``` + +For Teams jobs: + +- `teams_tenant_id` must be present, non-empty, and bounded; +- `thread_id` is rejected because the trusted conversation already encodes the + Teams Personal, groupChat, or channel route; +- fields used only by agent-writable usercron remain rejected in baseline config; +- a Teams-specific field on any other platform is rejected as a configuration + error; and +- `bot_framework_channel_id` is the verified Teams transport constant + `msteams`, while `app_id` comes from the configured Gateway credential. Neither + is operator-selectable in Core. + +Gateway therefore reconstructs and validates the complete registry key: + +```text +(configured_app_id, teams_tenant_id, "msteams", channel) +``` + +The raw `configToml`/`configUrl` chart contract remains authoritative; this proposal does +not restore removed Helm field rendering or create a second cron configuration +surface. + +### 3. Platform-neutral Core route marker + +`ChannelRef` gains an optional bounded persistent-conversation target carrying +only tenant ID, Bot Framework channel ID, and conversation ID. It participates +in routing equality so two distinct persistent targets cannot alias, but the +existing session key continues to use the Teams conversation route and does not +serialize the target into ACP sender context. + +All existing reactive and non-Teams `ChannelRef` values set the field to +`None`. A Teams cron `ChannelRef` sets it before the visible trigger send; clones +and returned `MessageRef` values preserve it for agent response chunks and +turn-local bot-owned mutations. + +### 4. Additive Gateway capability and wire field + +`AdapterCapabilities` gains +`supports_persistent_conversation_send`, defaulting to `false`. Gateway +advertises it for Teams only when: + +- the trusted persistent registry opened safely; +- Teams send ACK is supported; and +- Standalone reports exactly one active Core consumer. + +`openab.gateway.reply.v1` gains an optional closed +`persistent_conversation` object containing the non-secret tenant, +Bot-Framework-channel, and conversation identity. It never contains app secret, +OAuth token, `serviceUrl`, message/activity history, or the serialized registry +record. `reply.channel.id` must equal the target conversation ID and proactive +frames have no inbound `reply_to` correlation. + +New Core does not emit this field unless the exact capability is available. +Gateway rejects an unnegotiated field, an unsupported topology, malformed or +mismatched identity, or a non-Teams use before lookup or HTTP. The existing +registration capability is not reused as an optimistic send capability, so +registry-only Gateway peers fail closed. + +### 5. Exact Gateway-local lookup + +Gateway combines the wire target with its configured app ID and validates all +fields before acquiring the registry lock. It also reapplies the current +Gateway tenant allowlist. It then obtains a clone only if the exact record is +active and non-expired. + +Missing, expired, disabled, revoked, cross-tenant, cross-conversation, wrong +Bot-Framework-channel, unsafe-endpoint, unavailable-registry, and ambiguous +records all return a bounded `Rejected` outcome without OAuth, Connector HTTP, +filesystem mutation, ACP work, or fallback to the ephemeral route cache. +Expired records are not refreshed by cron; only a new trusted inbound activity +can refresh or reactivate them. + +### 6. Delivery and turn ordering + +For each Teams execution: + +1. the scheduler builds the persistent target from operator config; +2. Gateway performs the exact active lookup and conversation write lock; +3. Gateway rechecks active state after locking; +4. the existing outcome-aware Connector path posts one visible cron trigger; +5. only `Delivered` with a non-empty real activity ID allows session/ACP work; +6. agent response sends and ordered chunks repeat the active lookup using the + preserved target; and +7. turn-local ownership records permit only the activities created by this + process to be edited, deleted, or reacted to. + +Teams is added to the scheduler's threadless platforms. It never calls +`create_thread` or `rename_thread`, and no operator-cron path creates a new Teams +conversation. Personal, groupChat, and channel behavior comes from the trusted +stored conversation itself. + +### 7. Registry reconciliation + +After a Connector write, Gateway updates the exact registry key without holding +its filesystem mutex across network I/O: + +- `Delivered` clears one prior consecutive forbidden-write count; +- an exact HTTP 403 `MessageWritesBlocked` or + `BotNotInConversationRoster` increments the count and disables the active + record on the second consecutive result; +- generic 401/403, 404, 413, 429, other 4xx, 5xx, timeout, disconnect, and every + `Unknown` outcome leave state unchanged; and +- only a later trusted inbound promotion can reactivate a disabled or revoked + record. + +The 403 classifier parses only bounded structured error bodies and emits a +bounded internal reason code; it does not log or persist the response body or +user identity. A registry reconciliation failure is logged count-only and does +not rewrite the already authoritative Connector outcome. No outcome is retried +inside the cron execution. + +### 8. Reconnect-aware Standalone proxy + +Main creates one stable `ChatAdapter` proxy before spawning the existing +Standalone Gateway connection loop and registers that proxy for Teams cron. +Each connection generation installs its negotiated concrete adapter; disconnect +clears only that generation and wakes pending requests. The proxy never opens a +socket itself. + +A call made while disconnected or before the persistent-send capability is +negotiated is rejected before wire output. A call whose frame was accepted but +whose ACK is lost remains `Unknown`. After reconnect, later independent cron +executions use the new adapter and re-resolve the durable record, while an old +connection cannot clear or satisfy requests owned by the new generation. + +### 9. Observability and privacy + +Teams cron logs may expose platform, schedule/source class, operation class, +outcome class, elapsed time, aggregate registry state counts, and bounded reason +codes. They must not expose the configured prompt, app/tenant/conversation, +Team/channel/activity/sender IDs, target object, full registry path, +`serviceUrl`, credentials, or Connector response body. + +The scheduled prompt necessarily enters the selected ACP session, but the +persistent target does not. Tests and validation records use only synthetic values or sanitized counts, +order, and UI structure. + +## Compatibility and Rollout + +| Core | Gateway | Behavior | +| --- | --- | --- | +| old | old | Teams cron remains unsupported. | +| old | new | No persistent target is emitted; reactive registry behavior is unchanged. | +| new | registry-only Gateway | Capability defaults false; Teams cron stops before wire/ACP. | +| new | new, registry off | Capability false; Teams cron stops before wire/ACP. | +| new | new, registry on | Exact active lookup, required ACK, and reconciliation are enabled. | + +Existing non-Teams cron jobs retain their defaults and routing. A recommended +Standalone rollout is Core first with no imminently firing Teams job, verify the +old Gateway fail-closed path, then upgrade Gateway, verify the negotiated +capability, and only then enable a Teams baseline job. Gateway rollback leaves +the registry file untouched; a new Core paired with the rolled-back Gateway simply +cannot fire Teams cron. + +## Acceptance criteria + +Automated coverage must include: + +1. additive config parsing/defaults plus Teams tenant, field-bound, thread, and + cross-platform validation; +2. baseline Teams acceptance and unconditional usercron Teams rejection; +3. `VALID_PLATFORMS`, threadless behavior, configured-platform detection, and + exactly one selected cron adapter in Unified and Standalone modes; +4. stable Standalone proxy behavior before connect, after hello, during ACK, + after disconnect, and after a new connection generation; +5. additive capability and target wire round trips, missing-field defaults, old + peers, unsupported topology, and no pre-negotiation send; +6. exact complete-key lookup with current tenant allowlist and no HTTP for + missing/expired/disabled/revoked/mismatched targets; +7. one real-ID trigger before session/ACP work, no synthetic Teams thread, and + target preservation through content chunks and turn-local ownership; +8. `Delivered`, `Rejected`, and `Unknown` propagation with no blind retry; +9. exact blocked/not-in-roster parsing, two-result disable, successful reset, + generic-403/429/5xx/timeout no-state-change, and reconciliation-write failure; +10. same-conversation serialization without cross-conversation head-of-line + blocking; +11. Unified/Standalone parity and Core-first/new-old rolling combinations; +12. serialization/logging guards proving that persistent records, target IDs, + response bodies, `serviceUrl`, credentials, and prompts do not leak to ACP, + responses, or logs; and +13. targeted Core/Gateway/Unified/platform-schema tests, config documentation, + changed-line formatting, shipping-target Clippy, LSP, links, secret scans, + and `git diff --check`. + + +## Consequences + +### Positive + +- Teams scheduled delivery reuses the trust already proven by PR 11. +- The first platform write gates agent cost and every write retains structured + delivery semantics. +- Core selects a complete logical target without receiving `serviceUrl` or the + stored reference. +- Explicit capability negotiation preserves rolling fail-closed behavior. +- Usercron cannot turn agent filesystem access into arbitrary Teams proactive + authority. + +### Negative + +- Operators must copy a tenant ID and conversation ID into baseline config. +- `ChannelRef`, capability DTOs, and the mirrored Gateway reply schema gain one + more additive routing concept. +- Standalone needs a reconnect-aware adapter proxy shared with the scheduler. +- Each proactive write performs a registry lookup and may perform an atomic + state reconciliation. +- A blocked installation is disabled only after two exact outcomes. + +## Alternatives Rejected + +1. **Allow `platform = "teams"` in usercron.** The documented agent-writable file + has no initiating-user/scope binding and would grant arbitrary registry + selection. +2. **Lookup by conversation ID alone.** PR 11 explicitly requires the complete + app/tenant/Bot-Framework-channel/conversation identity. +3. **Put `serviceUrl` in `[[cron.jobs]]` or Core.** This bypasses registry state, + refresh, revocation, endpoint validation, and the established secret boundary. +4. **Reuse `supports_conversation_registry` as send support.** A PR 11 Gateway + can register but cannot proactively resolve and send; optimistic reuse would + break rolling compatibility. +5. **Create a second Standalone WebSocket for cron.** Broadcast fan-out would + create an unsupported second Core consumer and duplicate inbound events. +6. **Create a new Teams conversation at fire time.** It requires different + authority and platform semantics, cannot create group chats/channels, and + bypasses the trusted stored route. +7. **Treat every 403 as uninstall evidence.** Authentication/config failures and + unrelated forbidden operations must not disable a trusted route. +8. **Retry timeout, disconnect, or 5xx automatically.** A POST may already have + committed, so retry can duplicate a user-visible scheduled activity. +9. **Apply Discord thread creation to Teams.** The persistent conversation + already represents the Teams routing surface; a synthetic child thread is + neither portable nor authorized. + +## References + +- [Microsoft: Send proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +- [Microsoft: Send and receive targeted messages](https://learn.microsoft.com/en-us/microsoftteams/platform/agents-in-teams/targeted-messages) +- [Microsoft: Conversation events and installation updates](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events) +- [Microsoft: Bot Connector authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0) +- [OpenClaw Teams proactive send at reviewed revision](https://github.com/openclaw/openclaw/blob/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src/sdk-proactive.ts) +- [OpenClaw Teams send-context resolution at reviewed revision](https://github.com/openclaw/openclaw/blob/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src/send-context.ts) +- [Hermes Teams adapter at reviewed revision](https://github.com/NousResearch/hermes-agent/blob/00e5a361b60f621ae2246dffdd0a0252895d8493/plugins/platforms/teams/adapter.py) diff --git a/docs/adr/teams-text-command-parity.md b/docs/adr/teams-text-command-parity.md new file mode 100644 index 000000000..e06d2b249 --- /dev/null +++ b/docs/adr/teams-text-command-parity.md @@ -0,0 +1,323 @@ +# ADR: Teams Text Command Parity + +- **Status:** Proposed +- **Date:** 2026-08-13 +- **Author:** @NeoHsu +- **Related:** + - [Slash commands](../slash-commands.md) + - [Teams typed scope and mention routing](teams-typed-scope-and-mention-routing.md) + - [Multi-platform adapters](multi-platform-adapters.md) + +--- + +## Context + +Teams bot command menus do not create a Discord-style interaction callback. +The stable Teams app-manifest `bots[].commandLists[]` surface inserts configured +text into the compose box, and the user sends it as an ordinary authenticated +`message` activity. OpenAB must therefore parse commands only after the normal +Bot Framework validation, typed scope, identity, and structured-mention gates. + +The first parity set is: + +- `/models` +- `/agents` +- `/cancel` +- `/reset` +- `/cancel-all` +- `/usage` + +At the reviewed design baseline, the implementation was split across platform entry points: + +| Command | Discord native interaction | Gateway text path | +| --- | --- | --- | +| `/models` | Ephemeral select menu | Numbered text list through `/models` and `/model …` | +| `/agents` | Ephemeral select menu | Numbered text list through `/agents` and `/agent …` | +| `/cancel` | Implemented | Implemented in both Standalone and Unified loops | +| `/reset` | Implemented | Implemented in both Standalone and Unified loops | +| `/cancel-all` | Implemented | Missing | +| `/usage` | Ephemeral, Kiro-specific ACP query | Missing | + +The two Gateway paths also duplicated command dispatch. At that baseline, the +Standalone path executed inside the WebSocket reader and used an unacknowledged +fire-and-forget response to avoid waiting for a reply that only the same reader +could dispatch. That was incompatible with Teams required send acknowledgements. + +`/usage` has an additional privacy boundary. Discord responses are ephemeral, +but an ordinary Teams reply in a group chat or Team channel is visible to the +conversation. Account plan, limit, and overage information must not be exposed +there merely because the command text is valid. + +## Prior Art + +### OpenClaw + +OpenClaw separates a shared command registry and parser from native-platform +fast paths. Definitions describe native names, text aliases, scope, arguments, +and tiers, while authorization is resolved before command execution. Unknown or +colliding prefixes are not accidentally consumed. + +- [Shared command registry](https://github.com/openclaw/openclaw/blob/7179d21d977aacd0b07c0d5b12c31d1be251df7d/src/auto-reply/commands-registry.shared.ts) +- [Boundary-aware text parser](https://github.com/openclaw/openclaw/blob/7179d21d977aacd0b07c0d5b12c31d1be251df7d/src/auto-reply/reply/commands-slash-parse.ts) +- [Native command fast path](https://github.com/openclaw/openclaw/blob/7179d21d977aacd0b07c0d5b12c31d1be251df7d/src/auto-reply/reply/get-reply-native-slash-fast-path.ts) + +### Hermes Agent + +Hermes exposes a central messaging command surface, applies a separate +per-platform and per-scope command-access policy, and declares native command +manifests independently from command execution. Relay interactions normalize +back into the same command dispatcher rather than creating a second handler. + +- [Messaging command handlers](https://github.com/NousResearch/hermes-agent/blob/a871948d8d4b0f774d4ec40467bab1078a9f28d5/gateway/slash_commands.py) +- [Per-platform command access](https://github.com/NousResearch/hermes-agent/blob/a871948d8d4b0f774d4ec40467bab1078a9f28d5/gateway/slash_access.py) +- [Relay command manifest](https://github.com/NousResearch/hermes-agent/blob/a871948d8d4b0f774d4ec40467bab1078a9f28d5/gateway/relay/command_manifest.py) + +OpenAB does not need either project's full registry or permission-tier system. +The useful common pattern is a small semantic command service with separate +ingress admission and platform rendering. + +## Decision + +### 1. Add one platform-neutral command service + +Core will own a small command module used by Discord native interactions, +Standalone Gateway events, and Unified Gateway events. It will contain: + +- an exact parser for the six canonical commands and the existing Gateway + `/model …` and `/agent …` compatibility forms; +- command execution against `SessionPool` and `Dispatcher`; +- structured, platform-neutral results for config options, session-control + acknowledgements, usage data, validation errors, and unavailable operations; +- bounded user-facing error classes rather than raw backend errors; and +- shared config-option selection validation. + +The command service will not depend on Serenity, Gateway wire types, Teams Bot +Framework types, Adaptive Cards, or any platform SDK. Platform adapters retain +presentation responsibilities: + +- Discord renders ephemeral messages, selects, pagination controls, and the + existing usage colour/footer embed. +- Teams and other approved text surfaces render bounded Markdown/plain text. + +### 2. Parse only standalone, boundary-valid commands + +Outer whitespace is ignored. Canonical command names are lowercase ASCII and +must end at the input boundary because the first set takes no arguments. +Recognized compatibility forms retain their current syntax: + +```text +/model +/model list +/model set +/agent +/agent list +/agent set +``` + +A known command with unsupported trailing arguments returns a bounded usage +message. Prefix collisions such as `/reset-now`, `/usage-report`, or +`/cancel-all-now` are not commands and continue through the ordinary agent +prompt path. Unknown slash-prefixed text also continues to the ACP backend so +agent-native commands such as `/compact` remain usable. + +Command interception occurs after structural, L2 scope, L3 identity, and Teams +recipient-mention handling, but before attachment materialization and dispatcher +submission. A recognized command does not create a session, consume an agent +turn, add queued/progress reactions, create a placeholder, or download an +attachment. + +### 3. Keep one logical-thread command key + +Commands use the same session key as normal dispatch: + +```text +{platform}:{thread_id-or-channel_id} +``` + +`/reset` and `/cancel-all` clear all dispatcher handles whose key belongs to the +same `(platform, logical_thread_id)`, including every per-sender lane. They do +not affect another platform or conversation with a coincidentally equal native +ID. + +### 4. Define the six command semantics + +| Command | Core behavior | +| --- | --- | +| `/models` | Require existing session config options; return model options with current selection. Discord keeps its paginated select UI. Text rendering is current-first, displays at most 25 entries, and reports the omitted count. Existing `/model set …` searches the full option set. | +| `/agents` | Same as `/models`, accepting both `agent` and `mode` ACP categories. Existing `/agent set …` searches the full option set. | +| `/cancel` | Send one lock-free ACP `session/cancel` notification for the logical session. Do not clear buffered messages. | +| `/cancel-all` | Remove and abort all buffered dispatcher lanes for the logical thread, then send the same ACP cancel notification when a session exists. Report only whether buffering was cleared, not a race-prone exact count. | +| `/reset` | Remove and abort all buffered lanes, issue best-effort ACP cancellation, purge active/suspended/persisted session state through `SessionPool::reset_session`, and let the next ordinary message create a fresh session. | +| `/usage` | Require an active session, a backend that supports the existing usage extension, and a response surface proven private. Return the existing plan, breakdown, overage, currency, and reset information without logging it. | + +Config mutations validate the requested `config_id` and value against the +current active session options before calling `session/set_config_option`. +Discord component payloads and text set commands use the same validation. + +No command retries an ACP control notification or a platform response. A +platform write that is `Rejected` or `Unknown` remains terminal for that +response. + +### 5. Preserve privacy and trust before execution + +All command entry points must pass their normal platform admission before the +service is called. + +For Teams: + +1. JWT, tenant, required route fields, and public-cloud service URL are already + validated by Gateway. +2. Structural bot/mention filtering runs. +3. Typed L2 scope and L3 identity admission runs. +4. Only an authenticated recipient mention entity is removed. +5. The remaining text is parsed as a command. + +Plain-text `@OpenAB`, an unbound `OpenAB`, malformed typed scope, a +disallowed surface, or a denied identity cannot invoke a command. + +Discord native interactions will be routed through the existing adapter-level +DM/channel/user policy and shared L3 identity gate before a shared command is +executed. Denial is acknowledged ephemerally and does not disclose another +user's or session's state. + +`/usage` executes only when the response is private by construction: + +- Discord native interaction: ephemeral response; +- Teams: authenticated typed `personal` scope with `is_dm = true`. + +A Team channel, group chat, or old Teams event without typed privacy proof gets +a generic private-chat-only response and does not call the ACP usage extension. +No account values are logged or included in that rejection. + +### 6. Keep Standalone and Unified execution equivalent + +Both Gateway paths will call the same post-gate command helper. The duplicated +`/reset`, `/cancel`, and config-command blocks are removed. + +The Standalone WebSocket reader must never await a command response whose +required Gateway ACK is dispatched by that same reader. It spawns bounded +command execution/delivery work, continues reading frames, and uses the normal +outcome-aware `ChatAdapter` send path from the spawned task. Unified uses the +same command service and renderer without a wire hop. + +Command response delivery therefore follows negotiated Teams semantics: + +- valid new-peer hello: required real-ID send ACK; +- old Gateway or no valid hello: existing legacy send behavior; +- first `Rejected` or `Unknown`: stop without retry or a second warning send. + +No Gateway protocol version or capability field is added. + +### 7. Add a conservative Teams manifest command menu + +The documented manifest v1.25 profile will add classic +`bots[].commandLists[]`. Titles contain the exact text command, including the +leading slash. + +The Personal list advertises all six commands. The Team/group-chat list omits +`/usage` and advertises the other five. This is discoverability only; selecting +an item still produces an ordinary message and all runtime trust/mention gates +remain authoritative. + +This proposal does **not** enable the newer `supportsTargetedMessages` plus +`commandLists[].triggers = ["slash"]` agent surface. Microsoft's current agent +slash-command guidance says that surface switches group conversations into +private targeted-message mode. OpenAB does not negotiate, route, or support that privacy model, and manifest +v1.25 does not define those fields. + +Changing an installed app manifest remains an operator-controlled package +upgrade. Runtime deployment does not silently mutate a tenant app package. + +### 8. Keep observability content-free + +One command completion record may include platform, canonical command name, +semantic outcome class, and response write outcome. It must not include command +arguments, option values, usage values, sender/conversation/activity IDs, +serialized responses, tokens, or URLs. + +## Rolling Compatibility + +| Core | Gateway / surface | Result | +| --- | --- | --- | +| old Core | new Gateway or unchanged manifest | Existing partial text-command behavior; new Gateway does not execute commands. | +| new Core | old Gateway / no valid hello | Ordinary event decoding remains compatible; non-sensitive commands use legacy response delivery. `/usage` fails closed without authenticated typed privacy proof. | +| new Core | new Gateway | Shared service plus negotiated response ACKs. | +| new Unified | embedded Teams adapter | Same parser, execution, privacy, and result semantics without WebSocket transport. | +| any runtime | old installed manifest | Commands remain manually typeable; no menu is required for correctness. | +| any runtime | updated command-list manifest | Menu selection sends ordinary text; runtime gates remain authoritative. | + +## Acceptance criteria + +Automated verification must cover: + +- exact command boundaries, outer whitespace, known invalid arguments, and + unknown slash text passing through to ACP; +- all six semantic command results plus existing `/model` and `/agent` + compatibility forms; +- current-first 25-entry text rendering with deterministic truncation count; +- full-option validation for config selection and rejection of stale or forged + IDs/values before ACP mutation; +- `/cancel` preserving buffers, `/cancel-all` clearing every lane, and `/reset` + clearing every lane plus session state without cross-thread/platform effects; +- active, absent, unsupported, malformed, over-limit, and no-cap usage reports; +- `/usage` denied before backend access on public or unproven-private surfaces; +- Teams structural → typed L2 → L3 → mention cleanup → command ordering in both + Standalone and Unified paths; +- recognized command events skipping attachments, dispatcher submission, + reactions, processing status, and progressive placeholders; +- Standalone command execution not blocking the WebSocket response reader; +- required-ACK response `Delivered`, `Rejected`, `Unknown`, and timeout behavior + without retry or duplicate response; +- new Core ↔ old Gateway/no-hello behavior and Unified parity; +- Discord interaction admission, ephemeral presentation, config pagination, and + existing `/models`, `/agents`, `/cancel`, `/cancel-all`, `/reset`, and `/usage` + regressions; +- manifest v1.25 schema validation, command-list scope separation, command count, + and absence of targeted-message, Graph, RSC, delegated, or Adaptive Card + permissions; and +- platform-schema conformance plus existing Core/Gateway/Unified tests. + + +## Consequences + +### Positive + +- Command semantics stop drifting between Discord, Standalone, and Unified. +- Teams gains the missing control and usage commands without a new protocol or + permission. +- Usage data cannot be accidentally published to a group or channel. +- Required ACKs can be used without blocking the Standalone WebSocket reader. +- Manifest discovery remains independent from runtime authorization. + +### Negative + +- The shared service introduces structured semantic results and platform + renderers instead of a single string-returning helper. +- Discord native command admission must be made explicit during the refactor. +- Teams text menus cannot match Discord's interactive select controls without + the deferred Adaptive Card work. +- Operators must install a revised app package before command-menu entries + appear. + +## Alternatives Rejected + +1. **Copy Discord handlers into Teams.** This preserves drift and imports + platform-specific interaction assumptions into ordinary messages. +2. **Forward every command to the agent.** Session control and account usage are + broker responsibilities and must work without consuming an agent turn. +3. **Keep the duplicated Gateway blocks.** `/cancel-all` and `/usage` would still + diverge, and Standalone response ACKs would remain unsafe. +4. **Publish `/usage` in all scopes.** Teams ordinary replies are not ephemeral + and could expose account information. +5. **Enable targeted messages now.** The newer manifest surface changes group + privacy and delivery semantics that OpenAB has not modeled or validated. +6. **Use Adaptive Cards for parity.** Cards add a separate callback trust path + and are explicitly deferred. +7. **Add a new Gateway command protocol.** Commands already arrive as ordinary + authenticated events; a protocol change adds rolling risk without need. + +## References + +- [Microsoft: expose slash commands from agents and apps](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) +- [Microsoft Teams manifest v1.25 schema](https://developer.microsoft.com/en-us/json-schemas/teams/v1.25/MicrosoftTeams.schema.json) +- [Microsoft classic bot command-menu source at reviewed commit](https://github.com/MicrosoftDocs/msteams-docs/blob/c4611ef2586b/msteams-platform/bots/how-to/create-a-bot-commands-menu.md) diff --git a/docs/adr/teams-trusted-persistent-conversation-registry.md b/docs/adr/teams-trusted-persistent-conversation-registry.md new file mode 100644 index 000000000..fd8b164e2 --- /dev/null +++ b/docs/adr/teams-trusted-persistent-conversation-registry.md @@ -0,0 +1,375 @@ +# ADR: Teams Trusted Persistent Conversation Registry + +- **Status:** Proposed +- **Date:** 2026-08-19 +- **Author:** @NeoHsu +- **Related:** + - [Teams ephemeral ingress state](teams-ephemeral-ingress-state.md) + - [Teams typed scope and mention routing](teams-typed-scope-and-mention-routing.md) + - [Gateway capability and delivery semantics](gateway-capabilities-and-delivery-semantics.md) + - [Teams real send acknowledgement](teams-real-send-acknowledgement.md) + - [Basic cron scheduler](basic-cronjob.md) + +--- + +## Context + +OpenAB can reply to an authenticated Teams activity only while its bounded +Gateway-local `TeamsIngressRoute` remains available. That route contains the +validated Bot Framework `serviceUrl` and is indexed by an OpenAB event ID. It is +deliberately process-local, expires after `route_ttl_secs`, and disappears when +the Gateway restarts. + +Operator-scheduled delivery needs a trustworthy Teams destination after the +original turn and ephemeral route have ended. Persisting every JWT-valid +webhook is unsafe because Teams L2 scope and L3 sender identity are decided in +Core, after Gateway publication. Persisting only after an agent reply is also +incorrect: a trusted conversation remains a valid destination when a command +short-circuits, an attachment is rejected, the ACP backend fails, or the turn is +cancelled. + +The trust and secret boundary is therefore split: + +- Gateway alone validates Bot Framework JWT, tenant, endpoint, and route fields + and retains `serviceUrl`; +- Core alone has the authoritative shared L2/L3 Allow decision; and +- neither Core nor an ACP child may receive a persistent conversation + reference or `serviceUrl`. + +This proposal creates the registry and post-trust promotion handshake. It does +not send proactive messages or schedule work. + +## Constraints + +- Existing deployments must remain behaviorally unchanged unless an operator + configures a registry path. +- A denied, malformed, unauthenticated, cross-tenant, or expired route must + never create or refresh a persistent record. +- The registry is routing authority and must be treated as sensitive state even + though it contains no bot credential or message body. +- `serviceUrl` remains Gateway-local and must not appear in Gateway wire events, + ACP input, command responses, or application logs. +- Standalone and Unified mode must apply the same promotion and storage rules. +- The baseline supports one Gateway process and one direct Core consumer; this + is not a distributed registry or durable inbox. + +## Prior Art and Industry Research + +Research was performed against immutable source revisions on 2026-08-19. + +### Microsoft Teams and Bot Framework + +Microsoft requires a bot to retain a `conversationId` or +`conversationReference` for out-of-context delivery and recommends using the +`serviceUrl` from the incoming activity. The app must already be installed in +the target scope. A blocked or uninstalled bot may receive HTTP 403 with +`MessageWritesBlocked`; Teams also emits authenticated `installationUpdate` +activities with `action = add` or `remove`. + +OpenAB adopts the incoming-reference and uninstall-signal model, but adds its +own shared L2/L3 promotion gate because JWT validation alone is not OpenAB user +or scope authorization. This proposal does not use Microsoft Graph to install the app +or discover new conversations. + +### OpenClaw + +Reviewed revision: +[`4994f7bacf308269a0770b4a912c44a746cccec7`](https://github.com/openclaw/openclaw/tree/4994f7bacf308269a0770b4a912c44a746cccec7). + +OpenClaw's Teams plugin stores conversation references in keyed SQLite state, +hashes the conversation ID for its storage key, bounds retained conversations, +uses a one-year TTL, merges sparse refreshes, and validates stored +`serviceUrl` hosts before proactive use: + +- [`conversation-store-state.ts`](https://github.com/openclaw/openclaw/blob/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src/conversation-store-state.ts) +- [`conversation-store-helpers.ts`](https://github.com/openclaw/openclaw/blob/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src/conversation-store-helpers.ts) +- [`bot-framework-service-url.ts`](https://github.com/openclaw/openclaw/blob/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src/bot-framework-service-url.ts) + +Its normal admitted-message path asynchronously upserts the reference after +access checks, although its pairing path intentionally stores a reference for a +not-yet-allowlisted DM. OpenAB adopts bounded versioned storage, sparse-field +refresh, and endpoint revalidation. It diverges by prohibiting pairing or any +other denied activity from promotion, keying by app plus tenant plus Bot +Framework channel plus conversation, and recording disabled/revoked state +rather than exposing every stored route as immediately usable. + +### Hermes Agent + +Reviewed revision: +[`00e5a361b60f621ae2246dffdd0a0252895d8493`](https://github.com/NousResearch/hermes-agent/tree/00e5a361b60f621ae2246dffdd0a0252895d8493). + +Hermes keeps Teams `ConversationReference` objects in an in-memory `chat_id` +map for cards and captures them before its shared `handle_message` admission. +The map disappears on restart. Separate-process cron instead requires an +operator-configured home conversation, tenant, and service URL; that path +allowlists service hosts and validates conversation IDs: + +- [`plugins/platforms/teams/adapter.py`](https://github.com/NousResearch/hermes-agent/blob/00e5a361b60f621ae2246dffdd0a0252895d8493/plugins/platforms/teams/adapter.py) + +OpenAB adopts the explicit endpoint and identifier validation but not the +pre-admission cache or static global home route. Those approaches cannot prove +that a dynamically observed conversation passed OpenAB's shared L2/L3 gate and +do not provide restart-safe per-conversation authority. + +## Decision + +### Opt-in configuration + +Persistence is disabled when `teams.conversation_registry_path` is absent or +empty. This preserves all existing route and filesystem behavior. + +When configured, the registry resolves: + +| Setting | Default when enabled | Purpose | +| --- | ---: | --- | +| `conversation_registry_path` | none | Registry JSON file; absence disables the feature | +| `conversation_registry_max_entries` | `1000` | Independent persistent-record cap | +| `conversation_registry_ttl_secs` | `31536000` | One-year active/disabled retention window | + +Standalone environment fallbacks are +`TEAMS_CONVERSATION_REGISTRY_PATH`, +`TEAMS_CONVERSATION_REGISTRY_MAX_ENTRIES`, and +`TEAMS_CONVERSATION_REGISTRY_TTL_SECS`. + +A relative path resolves beneath `$HOME/.openab/`; an absolute path is accepted +as an explicit operator choice. Empty components, `.`/`..`, NUL, and any +existing symlink component are rejected. Helm does not silently create a new +Gateway PVC: Standalone operators must mount durable storage explicitly, while +Unified deployments may place the file on their existing HOME volume. + +### Versioned record + +The file has a closed top-level schema and records a generation number plus a +bounded list of entries. Each entry contains only: + +- schema version; +- bot app ID; +- tenant ID; +- Bot Framework channel ID; +- conversation ID and canonical type; +- validated `serviceUrl`; +- optional Team and Teams channel IDs; +- `last_validated_at` and `updated_at` wall-clock timestamps; +- `active`, `disabled`, or `revoked` state; +- a bounded reason code and consecutive forbidden-write count. + +No sender ID, user display name, message/activity ID, message text, attachment, +credential, OAuth token, or agent/session identifier is persisted. Configuration +accepts `1..=10000` entries; the serialized file is independently capped at 16 +MiB. App, tenant, Bot Framework channel, conversation type, and reason fields +are capped at 256 UTF-8 bytes; conversation, Team, and Teams channel IDs at +2048 bytes; and the already endpoint-validated service URL at 4096 bytes. + +The composite identity is: + +```text +(app_id, tenant_id, bot_framework_channel_id, conversation_id) +``` + +Lookup always requires the complete identity. Conversation IDs alone are never +global keys. + +### Trust-confirmed promotion + +Gateway advertises an additive Teams capability only when the registry is +configured, safely opened, and the supported single-consumer topology is in +use. New Core treats an absent capability as persistence unavailable and sends +no new command. Old Core and old Gateway combinations retain process-local +behavior. + +After structural admission and shared L2/L3 `Allow`, Core submits a bounded +`register_conversation` Gateway command before command, attachment, session, or +agent work can determine the turn outcome. The command carries only the +existing origin event ID and logical channel correlation. It never carries a +`serviceUrl` or serialized reference. + +Gateway resolves the origin event ID back to the still-valid authenticated +`TeamsIngressRoute`, verifies the reply channel and single-consumer topology, +and atomically upserts that route into the persistent registry. Missing, +expired, evicted, cross-conversation, or capability-mismatched routes are +rejected before filesystem mutation. + +Standalone registration runs in a tracked bounded task outside the WebSocket +reader because its correlated response can only be dispatched by that reader. +Unified invokes the same Gateway method in process. Registration is independent +from ACP success and is allowed for trusted recognized commands, ordinary +turns, and trusted turns that later become empty after mention cleanup. + +A storage failure does not retroactively reject the already authenticated +inbound message or prevent its current reactive turn. It returns a correlated +`Rejected` or `Unknown` registration outcome, leaves no new usable record, and +is never blindly retried. A later independently authenticated inbound activity +may safely attempt a fresh upsert. + +### Refresh and state transitions + +Only a newly authenticated ephemeral route plus a new shared L2/L3 Allow may +refresh address fields or `last_validated_at`: + +```text +Absent -- trusted inbound + committed write --> Active +Active -- trusted inbound + committed write --> Active (refreshed) +Disabled -- trusted inbound + committed write --> Active (re-enabled) +Revoked -- trusted inbound + committed write --> Active (re-installed proof) +``` + +Gateway-local delivery reconciliation exposes transitions for +operator-scheduled delivery: + +- two consecutive explicit blocked/not-in-roster 403 outcomes disable an active + record; +- a successful proactive write clears the consecutive forbidden count; +- 429, 5xx, timeout, disconnect, and ambiguous outcomes do not disable or + refresh a record; and +- an authenticated, tenant-allowed `installationUpdate` with `remove` or + `remove-upgrade` marks an existing matching record revoked without creating a + new record. + +An `installationUpdate add` does not activate a route by itself because it has +not passed user L3 admission. A subsequent trusted inbound activity may +reactivate the record. This proposal records and tests these transitions; +operator-scheduled delivery is the first caller of reconciliation. + +### Filesystem transaction and recovery + +All mutations are serialized by one registry lock and follow +clone → validate → write temporary file → flush → atomic rename → parent +flush → publish in-memory state. Readers never observe a candidate state before +its durable commit. + +On Unix, newly created directories are mode `0700` and the registry and +temporary files are mode `0600`. Existing registry permissions are tightened +before use. The final file, temporary file, and every existing path component +must be regular/non-symlink objects of the expected type. Record fields, entry +count, and total JSON bytes are bounded before allocation or replacement. + +Malformed JSON, an unknown schema version, an oversized file, unsafe path, or +permission failure makes the persistent capability unavailable and does not +replace the existing file. Reactive Teams messaging continues with the current +process-local route contract, while health/logging reports a content-free +registry initialization error. + +A crash before rename leaves the prior generation authoritative. Startup +ignores and safely removes only this registry's own validated temporary-file +pattern; unrelated files are never touched. No automatic migration from an +unknown future schema is attempted. + +### Capacity and retention + +Expired active or disabled entries are removed during load and mutation. +Revoked tombstones are retained within the same hard cap so a restart does not +silently erase the last known platform revocation before fresh trusted evidence +arrives. At capacity, the oldest expired, then disabled, then active record may +be evicted deterministically. Revoked records are not evicted to admit a new +key; saturation rejects the new promotion and emits a count-only warning. + +This registry is not shared between replicas. Two Gateway processes must not +write the same path. The registry performs no cross-process locking and advertises no +persistent capability when the existing topology report is unsupported. + +### Observability and privacy + +Logs and tests may expose schema/generation, aggregate entry/state counts, +operation class, result class, elapsed time, and bounded reason codes. They must +not expose app, tenant, conversation, Team/channel, activity, sender, full path, +or service URL values. Filesystem tests use synthetic identifiers only. + +The registry is never mounted into or passed through the agent subprocess +environment. Core and ACP observe only registration capability and the +correlated outcome. + +## Compatibility and rollout + +- Missing configuration means no disk read/write and no advertised capability. +- New Gateway with old Core never receives promotion and remains process-local. +- New Core with old or unavailable Gateway sees capability false and sends no + unsupported command. +- Registry state is Gateway-local; rolling Core replacement does not require a + file migration. +- A Gateway rollback leaves the versioned file untouched. The old binary does + not know its path unless separately configured. +- Enabling Standalone persistence requires an operator-owned durable mount and + a rollback backup. It does not recreate Core or change the Teams app + manifest. + +## Acceptance criteria + +Automated coverage must include: + +1. versioned round-trip, stable composite keys, sparse refresh, and complete + record-field bounds; +2. absent-path no-op defaults and config/env/Unified/Standalone equivalence; +3. `0600`/`0700`, traversal and symlink rejection, temporary-file isolation, + corrupt/unknown/oversized file fail-closed behavior, and old-or-new recovery + across an interrupted write; +4. deterministic TTL/capacity behavior and revoked-tombstone saturation; +5. no promotion for structural, tenant, typed L2, L3, malformed-scope, expired + route, cross-conversation, or unsupported-topology rejection; +6. promotion before command/attachment/session/agent side effects and + independence from ACP failure; +7. correlated Standalone response without WebSocket reader deadlock, timeout + retry, or unsolicited old-peer response; +8. Unified and Standalone promotion parity; +9. refresh/reactivation, consecutive blocked-403 disable, successful-write + reset, ambiguous-outcome no-disable, and authenticated uninstall revocation; +10. serialization/logging guards proving that route identifiers, `serviceUrl`, + messages, and credentials do not cross into Core, ACP, or logs; and +11. changed-line formatting, targeted Core/Gateway/platform-schema tests, + Clippy, config documentation, relative links, secret scanning, and + `git diff --check`. + + +## Consequences + +### Positive + +- Restart-safe Teams destinations inherit OpenAB's existing L2/L3 trust rather + than JWT validity alone. +- Gateway retains full ownership of service URLs and persistent route data. +- PR 12 receives an explicit active/disabled/revoked lookup contract instead of + reconstructing routes from session IDs. +- Default deployments gain no new filesystem side effect. +- Atomic versioned storage gives rollback and corruption behavior a testable + boundary. + +### Negative + +- One trusted inbound event now creates an additional asynchronous control + exchange when persistence is enabled. +- A JSON rewrite is proportional to the bounded registry size. +- Standalone operators must provide a durable Gateway mount explicitly. +- The one-writer restriction prevents active-active Gateway replicas from + sharing this file. +- Filesystem permissions protect confidentiality only as strongly as the + Gateway account and volume. + +## Alternatives Rejected + +1. **Persist every JWT-valid webhook in Gateway.** This bypasses Core L2/L3 and + turns denied conversations into proactive authority. +2. **Persist only after a bot reply succeeds.** Commands, ACP failures, and + cancelled turns would fail to register otherwise trusted conversations. +3. **Send the full conversation reference to Core.** This leaks `serviceUrl` + across the established Gateway-local boundary and risks agent exposure. +4. **Infer a route from Core session keys.** Session identifiers do not contain + authenticated service URL, tenant, app, or install-state evidence. +5. **Use only a static home channel.** It cannot represent per-conversation + trust, refresh, disable, or revocation and invites cross-scope mistakes. +6. **Enable a default HOME file automatically.** Existing deployments would + gain a new sensitive persistent side effect without operator opt-in or a + guaranteed durable Gateway mount. +7. **Use SQLite in PR 11.** A single-writer bounded registry does not yet need a + new database dependency; atomic JSON is inspectable and sufficient. A shared + transactional store remains the multi-replica follow-up. +8. **Delete on the first 403.** A single response may be transient or + misclassified; bounded consecutive evidence preserves the record while + stopping future use after sustained explicit rejection. + +## References + +- [Microsoft: Send proactive messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages) +- [Microsoft: Conversation events and installation updates](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/subscribe-to-conversation-events) +- [Microsoft: Bot Connector authentication](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0) +- [Microsoft: Bot Connector REST API](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0) +- [OpenClaw Teams conversation store at reviewed revision](https://github.com/openclaw/openclaw/tree/4994f7bacf308269a0770b4a912c44a746cccec7/extensions/msteams/src) +- [Hermes Teams adapter at reviewed revision](https://github.com/NousResearch/hermes-agent/blob/00e5a361b60f621ae2246dffdd0a0252895d8493/plugins/platforms/teams/adapter.py) From ce92a3fdb0d94a8c6d43bc6da4cfdea4dfe528ee Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:53:24 +0800 Subject: [PATCH 04/16] feat(gateway): negotiate platform delivery capabilities --- config.toml.example | 1 + crates/openab-core/src/adapter.rs | 225 +++++- crates/openab-core/src/config.rs | 74 ++ crates/openab-core/src/dispatch.rs | 20 +- crates/openab-core/src/gateway.rs | 651 ++++++++++++++---- crates/openab-gateway/src/adapters/feishu.rs | 101 +-- .../openab-gateway/src/adapters/googlechat.rs | 89 ++- .../openab-gateway/src/adapters/telegram.rs | 9 + crates/openab-gateway/src/adapters/wecom.rs | 9 + crates/openab-gateway/src/lib.rs | 411 ++++++++++- crates/openab-gateway/src/schema.rs | 316 ++++++++- docs/config-reference.md | 1 + docs/platforms/schema/lineworks.toml | 4 +- docs/platforms/schema/teams.toml | 379 +++++----- src/main.rs | 1 + src/unified_adapter.rs | 111 ++- 16 files changed, 1950 insertions(+), 452 deletions(-) diff --git a/config.toml.example b/config.toml.example index 00add1a9b..c75063b42 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 diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index fa7e95dba..257b4877e 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}; @@ -310,6 +310,119 @@ 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. The router converts non-character limits to a +/// conservative character bound until byte-aware splitting is implemented. +#[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, + } + } +} + +/// 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, +} + +/// 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, + 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, + 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, + }, +} + +/// 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, +} + // --- ChatAdapter trait --- #[async_trait] @@ -322,6 +435,39 @@ 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(), + } + }; + AdapterCapabilities { + can_edit: streaming_mode != StreamingMode::Disabled, + can_delete: streaming_mode != StreamingMode::Disabled, + streaming_mode, + show_streaming_placeholder: self.show_streaming_placeholder(), + message_limit, + status_backend: if self.uses_assistant_status() { + StatusBackend::Assistant + } else { + StatusBackend::Reactions + }, + ..AdapterCapabilities::default() + } + } + /// Send a new message, returns a reference to the sent message. async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result; @@ -605,10 +751,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 reaction_status = adapter + .capabilities(&ctx.thread_channel.platform) + .status_backend + == StatusBackend::Reactions; let reactions = Arc::new(StatusReactionController::new( self.reactions_config.enabled, @@ -617,7 +766,7 @@ impl AdapterRouter { self.reactions_config.emojis.clone(), self.reactions_config.timing.clone(), )); - if !assistant_status { + if reaction_status { reactions.set_queued().await; } @@ -632,7 +781,7 @@ impl AdapterRouter { ) .await; - if !assistant_status { + if reaction_status { match &result { Ok(()) => reactions.set_done().await, Err(_) => reactions.set_error().await, @@ -700,16 +849,15 @@ 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 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. @@ -717,8 +865,9 @@ impl AdapterRouter { // `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 native = streaming && capabilities.streaming_mode == StreamingMode::Native; + let assistant_status = capabilities.status_backend == StatusBackend::Assistant; + let reaction_status = capabilities.status_backend == StatusBackend::Reactions; // 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. @@ -745,7 +894,7 @@ impl AdapterRouter { let (mut rx, request_id) = conn.session_prompt(content_blocks).await?; if assistant_status { let _ = adapter.set_status(&thread_channel, "Thinking…").await; - } else { + } else if reaction_status { reactions.set_thinking().await; } @@ -780,7 +929,7 @@ impl AdapterRouter { } else { "…".to_string() }; - let msg = if adapter.show_streaming_placeholder() { + let msg = if capabilities.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 @@ -973,7 +1122,7 @@ impl AdapterRouter { let _ = adapter .set_status(&thread_channel, "Thinking…") .await; - } else { + } else if reaction_status { reactions.set_thinking().await; } } @@ -986,7 +1135,7 @@ impl AdapterRouter { &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 @@ -1030,7 +1179,7 @@ impl AdapterRouter { 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 @@ -1746,6 +1895,34 @@ 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!( + AdapterCapabilities::default().status_backend, + StatusBackend::None + ); + } + #[test] fn select_delivery_text_send_once_keeps_only_final_block() { // Simulates: narration "n1" → tool (answer_start→2) → narration "n2" diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index a9bc26abd..84fcb2edc 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 @@ -2321,6 +2329,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, @@ -3545,6 +3567,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 +3580,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/dispatch.rs b/crates/openab-core/src/dispatch.rs index 64ba68917..d0315a400 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; @@ -625,11 +625,13 @@ 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 { + // Apply 👀 only when the platform selected the reactions status backend. + // Assistant/typing/none backends must not leak into the emoji lifecycle. + let reaction_status = adapter + .capabilities(&thread_channel.platform) + .status_backend + == StatusBackend::Reactions; + if reaction_status { let queued_emoji = &target.reactions_config().emojis.queued; for msg in batch.iter() { let _ = adapter.add_reaction(&msg.trigger_msg, queued_emoji).await; @@ -779,9 +781,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, diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index a3b74adbd..114916e27 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1,5 +1,8 @@ use crate::acp::ContentBlock; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; +use crate::adapter::{ + AdapterCapabilities, AdapterRouter, ChannelRef, ChatAdapter, MessageLimit, MessageRef, + SenderContext, StatusBackend, StreamingMode, WriteOutcome, WriteOutcomeKind, +}; use anyhow::Result; use async_trait::async_trait; use futures_util::{SinkExt, StreamExt}; @@ -10,59 +13,39 @@ 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); -/// 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) -} - -/// 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) +/// 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, + 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, + } } /// Shared filter parameters for gateway event gating. @@ -211,6 +194,124 @@ 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, +} + +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 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 --- @@ -227,34 +328,72 @@ type SharedWsTx = Arc< >, >; +struct GatewayAdapterOptions { + platform_name: &'static str, + streaming: bool, + streaming_placeholder: bool, + telegram_rich_messages: bool, + gateway_ack_timeout_secs: u64, +} + pub struct GatewayAdapter { ws_tx: SharedWsTx, pending: PendingRequests, + capability_state: Arc, + legacy_capabilities: AdapterCapabilities, platform_name: &'static str, streaming: bool, streaming_placeholder: bool, telegram_rich_messages: bool, + ack_timeout: std::time::Duration, } impl GatewayAdapter { fn new( ws_tx: SharedWsTx, pending: PendingRequests, - platform_name: &'static str, - streaming: bool, - streaming_placeholder: bool, - telegram_rich_messages: bool, + capability_state: Arc, + options: GatewayAdapterOptions, ) -> Self { + let GatewayAdapterOptions { + platform_name, + streaming, + streaming_placeholder, + telegram_rich_messages, + gateway_ack_timeout_secs, + } = options; Self { ws_tx, pending, + capability_state, + legacy_capabilities: legacy_gateway_capabilities( + platform_name, + streaming, + streaming_placeholder, + ), platform_name, streaming, streaming_placeholder, telegram_rich_messages, + 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); + if !self.streaming { + capabilities.streaming_mode = StreamingMode::Disabled; + } + capabilities.show_streaming_placeholder &= self.streaming_placeholder; + (negotiated, capabilities) + } + + fn resolved_capabilities(&self, platform: &str) -> AdapterCapabilities { + self.resolved_capabilities_with_mode(platform).1 + } + /// Internal helper for send_message / send_message_with_reply. async fn send_gateway_reply( &self, @@ -262,11 +401,12 @@ impl GatewayAdapter { content: &str, quote_message_id: Option<&str>, ) -> Result { - let req_id = if self.streaming { - Some(format!("req_{}", uuid::Uuid::new_v4())) - } else { - None - }; + let (negotiated, capabilities) = self.resolved_capabilities_with_mode(&channel.platform); + 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); @@ -298,32 +438,63 @@ impl GatewayAdapter { return Err(e.into()); } 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(anyhow::anyhow!( + "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(anyhow::anyhow!( + "gateway rejected write ({code}): {message}" + )); + } + WriteOutcome::Unknown { code, message } => { + warn!( + request_id = %id, + error_code = %code, + error = %message, + "gateway write outcome unknown; not retrying" + ); + return Err(anyhow::anyhow!( + "gateway write outcome unknown ({code}): {message}" + )); + } + }, + Ok(Err(_)) if required_ack => { + return Err(anyhow::anyhow!("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(anyhow::anyhow!("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() } @@ -499,7 +670,11 @@ 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 send_message(&self, channel: &ChannelRef, content: &str) -> Result { @@ -617,12 +792,19 @@ 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); + 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())) @@ -660,32 +842,38 @@ impl ChatAdapter for GatewayAdapter { return Err(e.into()); } 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, .. } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected edit"); + Err(anyhow::anyhow!("edit rejected ({code}): {message}")) + } + WriteOutcome::Unknown { code, message } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway edit outcome unknown"); + Err(anyhow::anyhow!("edit outcome unknown ({code}): {message}")) + } + }, + Ok(Err(_)) if required_ack => { + Err(anyhow::anyhow!("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(anyhow::anyhow!("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,13 +886,20 @@ 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); + 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 = GatewayReply { schema: "openab.gateway.reply.v1".into(), reply_to: msg.message_id.clone(), @@ -719,19 +914,50 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("delete_message".into()), quote_message_id: None, - request_id: None, + request_id: request_id.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(error.into()); + } + + 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, .. } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected delete"); + Err(anyhow::anyhow!("delete rejected ({code}): {message}")) + } + WriteOutcome::Unknown { code, message } => { + warn!(request_id = %id, error_code = %code, error = %message, "gateway delete outcome unknown"); + Err(anyhow::anyhow!( + "delete outcome unknown ({code}): {message}" + )) + } + }, + Ok(Err(_)) => Err(anyhow::anyhow!("required delete ACK channel closed")), + Err(_) => { + self.pending.lock().await.remove(&id); + Err(anyhow::anyhow!("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,6 +985,7 @@ pub struct GatewayParams { pub streaming: bool, pub streaming_placeholder: bool, pub telegram_rich_messages: bool, + pub gateway_ack_timeout_secs: u64, pub stt: crate::config::SttConfig, } @@ -776,21 +1003,13 @@ pub async fn run_gateway_adapter( 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 gateway_ack_timeout_secs = params.gateway_ack_timeout_secs; let stt_config = params.stt; let connect_url = match ¶ms.token { @@ -835,13 +1054,23 @@ 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 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, + capability_state.clone(), + GatewayAdapterOptions { + platform_name: platform, + streaming, + streaming_placeholder, + telegram_rich_messages, + gateway_ack_timeout_secs, + }, )); let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); @@ -869,15 +1098,50 @@ 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) => { @@ -1664,27 +1928,118 @@ mod tests { use std::collections::HashSet; #[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 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 editable_platforms_still_allow_streaming() { + fn legacy_editable_platforms_preserve_configured_streaming() { for platform in [ "telegram", "slack", "discord", "feishu", - "teams", "googlechat", "wecom", + "teams", ] { - assert!( - platform_supports_streaming(platform), - "{platform} should still support streaming", - ); + 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 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); + + 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 (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 legacy_and_structured_gateway_responses_map_to_write_outcomes() { + let legacy: GatewayResponse = 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 + })) + .unwrap(); + assert_eq!( + legacy.write_outcome(), + WriteOutcome::Delivered { + message_id: Some("activity-1".into()) + } + ); + + let unknown: GatewayResponse = 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" + })) + .unwrap(); + assert_eq!( + unknown.write_outcome(), + WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "delivery may have completed".into() + } + ); + } + + #[test] + fn client_hello_wire_shape_is_additive_and_versioned() { + let value = serde_json::to_value(build_client_hello()).unwrap(); + 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] diff --git a/crates/openab-gateway/src/adapters/feishu.rs b/crates/openab-gateway/src/adapters/feishu.rs index c3df29e99..a379368b3 100644 --- a/crates/openab-gateway/src/adapters/feishu.rs +++ b/crates/openab-gateway/src/adapters/feishu.rs @@ -2550,14 +2550,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 +2617,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 +2673,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 +2687,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 +2745,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 +2781,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); } @@ -4663,6 +4670,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"); } diff --git a/crates/openab-gateway/src/adapters/googlechat.rs b/crates/openab-gateway/src/adapters/googlechat.rs index 12d274ee4..96bca8b30 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); @@ -2060,6 +2078,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())); } @@ -2104,6 +2123,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); diff --git a/crates/openab-gateway/src/adapters/telegram.rs b/crates/openab-gateway/src/adapters/telegram.rs index f4c381c1a..055b1f5c5 100644 --- a/crates/openab-gateway/src/adapters/telegram.rs +++ b/crates/openab-gateway/src/adapters/telegram.rs @@ -435,6 +435,9 @@ 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, } } else { let err = body["description"] @@ -449,6 +452,9 @@ 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, } } } @@ -459,6 +465,9 @@ 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, }, }; let json = serde_json::to_string(&gw_resp).unwrap(); diff --git a/crates/openab-gateway/src/adapters/wecom.rs b/crates/openab-gateway/src/adapters/wecom.rs index 97aa84551..921516539 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -466,6 +466,9 @@ impl WecomAdapter { thread_id: None, message_id: placeholder_id, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -502,6 +505,9 @@ impl WecomAdapter { thread_id: None, message_id: None, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); @@ -536,6 +542,9 @@ impl WecomAdapter { thread_id: None, message_id: msg_id, error: None, + outcome: None, + error_code: None, + retry_after_ms: None, }; if let Ok(json) = serde_json::to_string(&resp) { let _ = event_tx.send(json); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index d2b257c7d..517780961 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}; @@ -85,6 +86,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 +101,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 @@ -139,11 +142,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(), } } @@ -261,6 +267,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 +277,122 @@ 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 { + 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), + 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 self.teams.is_some() { + insert( + "teams", + AdapterCapabilities { + show_streaming_placeholder: true, + message_limit: characters(4096), + status_backend: 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), + 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), + 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. /// @@ -843,6 +966,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)), @@ -954,24 +1078,92 @@ async fn ws_handler( ws.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); + 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(), + }, + } +} + 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,10 +1171,37 @@ 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(); 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); + 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) => { info!( @@ -1101,8 +1320,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 +1410,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 +1424,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"); @@ -1224,6 +1455,15 @@ mod l1_audit_tests { }); 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.can_edit); + assert_eq!(teams.streaming_mode, super::schema::StreamingMode::Disabled); + assert!(teams.show_streaming_placeholder); + assert_eq!(teams.status_backend, super::schema::StatusBackend::None); // Missing secret → adapter disabled (same as env-only semantics). s.apply_teams_config(GatewayTeamsConfig { @@ -1300,6 +1540,151 @@ 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; + + 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()?) + } + + #[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!("ws://{addr}/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.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(()) + } + + #[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!("ws://{addr}/ws"); + let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; + wait_for_consumers(&state, 1).await?; + + let legacy_reply = schema::GatewayReply { + 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, + }; + 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..c942d8d06 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::collections::HashMap; // --- Event schema (ADR openab.gateway.event.v1) --- @@ -98,6 +99,107 @@ 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, +} + +#[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, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AdapterCapabilities { + pub send_ack: bool, + pub edit_ack: bool, + pub delete_ack: 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, + 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, Serialize, Deserialize)] @@ -125,7 +227,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 +265,91 @@ 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, +} + +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, + }, + 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, + }, + 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, + }, + } + } + + 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 { @@ -163,3 +379,101 @@ impl GatewayEvent { } } } + +#[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 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.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/docs/config-reference.md b/docs/config-reference.md index af30e0940..17913b1fc 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -129,6 +129,7 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl | `trusted_bot_ids` | string[] | `[]` | Bot IDs that bypass the bot filter even when `allow_bot_messages = false`. | | `streaming` | bool | `false` | Enable streaming (typewriter) mode — requires the gateway platform to support message editing. | | `streaming_placeholder` | bool | `true` | Show "…" placeholder at streaming start. Set `false` for platforms using drafts (e.g. Telegram Rich Messages). | +| `gateway_ack_timeout_secs` | u64 | `12` | Maximum wait for an operation ACK explicitly advertised by a negotiated gateway. Missing ACKs from legacy peers remain fire-and-forget. Must be greater than 0, less than `pool.prompt_hard_timeout_secs`, and greater than 10 when `platform = "teams"`. | | `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | | `max_buffered_messages` | u32 | `10` | Same as Discord. | | `max_batch_tokens` | u32 | `24000` | Same as Discord. | diff --git a/docs/platforms/schema/lineworks.toml b/docs/platforms/schema/lineworks.toml index 066944446..cc974038a 100644 --- a/docs/platforms/schema/lineworks.toml +++ b/docs/platforms/schema/lineworks.toml @@ -132,8 +132,8 @@ pr = "" [[openab_features]] feature = "streaming" status = "n_a" -note = "No edit API to drive post+edit streaming. The platform is listed in NON_EDITABLE_PLATFORMS so the core forces streaming off and the cosmetic edit/delete commands are dropped by the dispatcher." -source = ["crates/openab-core/src/gateway.rs#NON_EDITABLE_PLATFORMS", "crates/openab-gateway/src/adapters/lineworks.rs#dispatch_lineworks_reply"] +note = "No edit API to drive post+edit streaming. Negotiated and legacy platform capabilities both advertise streaming/edit as disabled; cosmetic edit/delete commands are dropped by the dispatcher." +source = ["crates/openab-gateway/src/lib.rs#gateway_capabilities", "crates/openab-core/src/gateway.rs#legacy_gateway_capabilities", "crates/openab-gateway/src/adapters/lineworks.rs#dispatch_lineworks_reply"] pr = "" [[openab_features]] diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 42a325e85..f298ef05c 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -6,339 +6,372 @@ schema_version = "2026-07-08" [platform] -name = "teams" +name = "teams" official_docs = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" -description = "Microsoft Teams bot reached via the Bot Framework / Azure Bot Connector REST activity protocol (not a direct Teams API)." +description = "Microsoft Teams bot reached via the Bot Framework / Azure Bot Connector REST activity protocol (not a direct Teams API)." # ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ [capability.transport] -kind = "webhook" -note = "Bot Framework POSTs an `Activity` JSON to the bot's `/api/messages` messaging endpoint (one endpoint only)." +kind = "webhook" +note = "Bot Framework POSTs an `Activity` JSON to the bot's `/api/messages` messaging endpoint (one endpoint only)." source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [capability.inbound_auth] scheme = "jwt_rs256" -note = "JWT bearer, RS256/RS384, signed by Bot Framework. L1 = OpenID Connect: fetch JWKS from `login.botframework.com` well-known config; validate `aud`=app_id, `iss`=`https://api.botframework.com`, `exp`, the `serviceurl` claim vs `activity.serviceUrl`, and channel endorsements." +note = "JWT bearer, RS256/RS384, signed by Bot Framework. L1 = OpenID Connect: fetch JWKS from `login.botframework.com` well-known config; validate `aud`=app_id, `iss`=`https://api.botframework.com`, `exp`, the `serviceurl` claim vs `activity.serviceUrl`, and channel endorsements." source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" [capability.threads] -model = "native" -note = "Mixed: channel posts form native reply chains — `conversation.id` encodes the root message ID and replies land in that chain. 1:1 and group chats are flat (no sub-threads). `replyToId` is used for context/reply-target, not routing." +model = "native" +note = "Mixed: channel posts form native reply chains — `conversation.id` encodes the root message ID and replies land in that chain. 1:1 and group chats are flat (no sub-threads). `replyToId` is used for context/reply-target, not routing." source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" [capability.slash_commands] supported = false -note = "No native `/`-command protocol for bots. A static command menu can be declared in the app manifest; selections arrive as ordinary `message` activities (plain text). Bots must parse commands from message text." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu" +note = "No native `/`-command protocol for bots. A static command menu can be declared in the app manifest; selections arrive as ordinary `message` activities (plain text). Bots must parse commands from message text." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu" [capability.mentions] method = "at_mention" -note = "`@mention` via `entities[]` of type `mention`; each mention entity carries `mentioned.id` + `mentioned.name`. In channel/group scope the bot only receives messages where it is @mentioned (unless RSC grants broader access). Bot detects itself by matching a mention's `mentioned.id` to `recipient.id`. Don't trust the text markup (``) — use `entities`." +note = "`@mention` via `entities[]` of type `mention`; each mention entity carries `mentioned.id` + `mentioned.name`. In channel/group scope the bot only receives messages where it is @mentioned (unless RSC grants broader access). Bot detects itself by matching a mention's `mentioned.id` to `recipient.id`. Don't trust the text markup (``) — use `entities`." source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" [capability.emoji_reactions] -bot_can_add = true -bot_can_remove = true +bot_can_add = true +bot_can_remove = true bot_receives_events = true -note = "Bot receives reaction events via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot add/remove own reactions supported via SDK reaction APIs / connector." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" +note = "Bot receives reaction events via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot add/remove own reactions supported via SDK reaction APIs / connector." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" [capability.edit_message] supported = true -note = "Bot can update its own already-sent message: `PUT /v3/conversations/{conversationId}/activities/{activityId}`. Requires caching the activityId returned by the original post." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" +note = "Bot can update its own already-sent message: `PUT /v3/conversations/{conversationId}/activities/{activityId}`. Requires caching the activityId returned by the original post." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" [capability.delete_message] supported = true -scope = "own" -note = "Own messages only: `DELETE /v3/conversations/{conversationId}/activities/{activityId}`. A bot cannot update or delete messages sent by users." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" +scope = "own" +note = "Own messages only: `DELETE /v3/conversations/{conversationId}/activities/{activityId}`. A bot cannot update or delete messages sent by users." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" [capability.rich_content] markdown = true -cards = true -buttons = true -note = "Markdown (`textFormat: markdown`), a subset of XML/HTML tags, and Adaptive Cards (buttons, inputs, images). Text-only messages don't support table formatting; rich cards support formatting in the `text` property only and don't support Markdown or tables. `suggestedActions` (`imBack` only, ≤6) work only in 1:1 chats and not alongside attachments." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" +cards = true +buttons = true +note = "Markdown (`textFormat: markdown`), a subset of XML/HTML tags, and Adaptive Cards (buttons, inputs, images). Text-only messages don't support table formatting; rich cards support formatting in the `text` property only and don't support Markdown or tables. `suggestedActions` (`imBack` only, ≤6) work only in 1:1 chats and not alongside attachments." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" [capability.attachments] -inbound = ["image", "file"] -outbound = ["image", "file"] +inbound = ["image", "file"] +outbound = ["image", "file"] max_size_mb = 1 -note = "Inbound: user can attach pictures/files. Outbound pictures ≤ 1024×1024 px and ≤ 1 MB, PNG/JPEG/GIF (animated GIF not supported); Markdown inline image renders at 256×256 by default (override via XML width/height). Non-image files are shared via attachment/card links (Graph/SharePoint), not raw upload in the activity. The 1 MB cap is the outbound-picture limit." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" +note = "Inbound: user can attach pictures/files. Outbound pictures ≤ 1024×1024 px and ≤ 1 MB, PNG/JPEG/GIF (animated GIF not supported); Markdown inline image renders at 256×256 by default (override via XML width/height). Non-image files are shared via attachment/card links (Graph/SharePoint), not raw upload in the activity. The 1 MB cap is the outbound-picture limit." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [capability.message_length_limit] max_chars = 0 -note = "No fixed character count — a byte/UTF-16 budget, not a char cap. ~100 KB per bot message (approximate; UTF-16, includes text + image links + @mentions + reactions; excludes base64-encoded images). Recommend keeping the message ≤ 80 KB to guarantee delivery. Over-limit → `413 RequestEntityTooLarge` with error code `MessageSizeTooBig`." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" +note = "No fixed character count — a byte/UTF-16 budget, not a char cap. ~100 KB per bot message (approximate; UTF-16, includes text + image links + @mentions + reactions; excludes base64-encoded images). Recommend keeping the message ≤ 80 KB to guarantee delivery. Over-limit → `413 RequestEntityTooLarge` with error code `MessageSizeTooBig`." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" [capability.dm_support] supported = true -note = "1:1 personal chat (`conversationType: personal`)." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" +note = "1:1 personal chat (`conversationType: personal`)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [capability.group_model] -kinds = ["personal", "groupChat", "channel"] -note = "Taxonomy: `personal` (1:1), `groupChat` (group chat), `channel` (team channel, has reply chains + `channelData.team`/`channel`). Bot install scopes: `personal`, `groupChat`/`groupchat`, `team`." +kinds = ["personal", "groupChat", "channel"] +note = "Taxonomy: `personal` (1:1), `groupChat` (group chat), `channel` (team channel, has reply chains + `channelData.team`/`channel`). Bot install scopes: `personal`, `groupChat`/`groupchat`, `team`." source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" [capability.group_sender_identity] stable_id = "yes" -note = "`activity.from.id` (`29:1...`) is a stable, always-present per-user id in group/channel events. `from.aadObjectId` (Entra object id) is also provided but may be absent for guests/anonymous; not consent-gated for basic id." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" +note = "`activity.from.id` (`29:1...`) is a stable, always-present per-user id in group/channel events. `from.aadObjectId` (Entra object id) is also provided but may be absent for guests/anonymous; not consent-gated for basic id." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" [capability.send_model] -model = "hybrid" +model = "hybrid" reply_token_ttl_sec = 0 max_objects_per_send = 0 -note = "Reply + proactive. Reply: POST activity to `v3/conversations/{id}/activities` using the per-conversation `serviceUrl`. `serviceUrl` can change and should be refreshed per inbound activity (no fixed reply-window token; OAuth token TTL ~ `expires_in`)." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" +note = "Reply + proactive. Reply: POST activity to `v3/conversations/{id}/activities` using the per-conversation `serviceUrl`. `serviceUrl` can change and should be refreshed per inbound activity (no fixed reply-window token; OAuth token TTL ~ `expires_in`)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [capability.proactive_push] -supported = true +supported = true quota_model = "metered" -note = "Allowed if the app is installed for the target scope (else `403 ForbiddenOperationException`/`BotNotInConversationRoster`). Per-bot-per-thread send-to-conversation: 7/1s, 8/2s, 60/30s, 1800/3600s; per-app-per-tenant global 50 RPS. Over-limit → `429 Too Many Requests` (also retry `412`/`502`/`504`); use exponential backoff." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit" +note = "Allowed if the app is installed for the target scope (else `403 ForbiddenOperationException`/`BotNotInConversationRoster`). Per-bot-per-thread send-to-conversation: 7/1s, 8/2s, 60/30s, 1800/3600s; per-app-per-tenant global 50 RPS. Over-limit → `429 Too Many Requests` (also retry `412`/`502`/`504`); use exponential backoff." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit" [capability.bot_to_bot] delivered = false -note = "Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels)." -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" +note = "Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels)." +source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [capability.typing_indicator] supported = true -note = "Bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter." -source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0" +note = "Bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter." +source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0" # ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ [[openab_features]] feature = "send_message" -status = "implemented" -note = "Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`." -source = ["crates/openab-core/src/gateway.rs#send_message", "crates/openab-gateway/src/adapters/teams.rs#send_activity"] -pr = "" +status = "implemented" +note = "Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`." +source = [ + "crates/openab-core/src/gateway.rs#send_message", + "crates/openab-gateway/src/adapters/teams.rs#send_activity", +] +pr = "" [[openab_features]] feature = "message_split" -status = "partial" -note = "Core splits via `split_delivery`; `GatewayAdapter::message_limit()` returns 4096 (hardcoded 'Telegram limit', not Teams' ~100 KB / UTF-16 budget) — chunking works but the bound is generic, not Teams-tuned." -source = ["crates/openab-core/src/adapter.rs#message_limit", "crates/openab-core/src/gateway.rs"] -pr = "" +status = "partial" +note = "Core splits via `split_delivery`; `GatewayAdapter::message_limit()` returns 4096 (hardcoded 'Telegram limit', not Teams' ~100 KB / UTF-16 budget) — chunking works but the bound is generic, not Teams-tuned." +source = [ + "crates/openab-core/src/adapter.rs#message_limit", + "crates/openab-core/src/gateway.rs", +] +pr = "" [[openab_features]] feature = "streaming" -status = "workaround" -note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). But the Teams gateway never dispatches `edit_message`, so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." -source = ["crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-gateway/src/adapters/teams.rs#update_activity"] -pr = "" +status = "workaround" +note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). But the Teams gateway never dispatches `edit_message`, so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." +source = [ + "crates/openab-core/src/gateway.rs#use_streaming", + "crates/openab-gateway/src/adapters/teams.rs#update_activity", +] +pr = "" [[openab_features]] feature = "reply_quote" -status = "not_implemented" -note = "`GatewayAdapter::send_message_with_reply` puts the target id into `quote_message_id` (the visual-quote field via `send_gateway_reply`), not `reply_to`. The Teams adapter reads only `reply.reply_to` (the triggering-event/origin id) → `replyToId`, and ignores `quote_message_id` entirely — so the intended visual quote never reaches Teams. `replyToId` is a reply-target/context id, not a visual quote." -source = ["crates/openab-core/src/gateway.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "not_implemented" +note = "`GatewayAdapter::send_message_with_reply` puts the target id into `quote_message_id` (the visual-quote field via `send_gateway_reply`), not `reply_to`. The Teams adapter reads only `reply.reply_to` (the triggering-event/origin id) → `replyToId`, and ignores `quote_message_id` entirely — so the intended visual quote never reaches Teams. `replyToId` is a reply-target/context id, not a visual quote." +source = [ + "crates/openab-core/src/gateway.rs#send_message_with_reply", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", +] +pr = "" [[openab_features]] feature = "edit_message" -status = "not_implemented" -note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) AND `handle_reply` has no `edit_message` branch — it falls through to `send_activity`, posting the new text as a fresh message." -source = ["crates/openab-core/src/gateway.rs#edit_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "not_implemented" +note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) AND `handle_reply` has no `edit_message` branch — it falls through to `send_activity`, posting the new text as a fresh message." +source = [ + "crates/openab-core/src/gateway.rs#edit_message", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", +] +pr = "" [[openab_features]] feature = "delete_message" -status = "not_implemented" -note = "The `delete_message` command is not handled in `handle_reply`; it falls through to `send_activity`. Platform supports DELETE, but the adapter never calls it." -source = ["crates/openab-core/src/gateway.rs#delete_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "not_implemented" +note = "The `delete_message` command is not handled in `handle_reply`; it falls through to `send_activity`. Platform supports DELETE, but the adapter never calls it." +source = [ + "crates/openab-core/src/gateway.rs#delete_message", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", +] +pr = "" [[openab_features]] feature = "emoji_reactions" -status = "not_implemented" -note = "`handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams." -source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "not_implemented" +note = "`handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams." +source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +pr = "" [[openab_features]] feature = "threads_topics" -status = "not_implemented" -note = "The `create_topic` command from `GatewayAdapter::create_thread` is not handled by Teams `handle_reply` (falls through to plain send). Inbound events set `thread_id: None` ('Teams conversations don't have sub-threads in the same way'). Core `create_thread` falls back to the same channel on timeout anyway." -source = ["crates/openab-core/src/gateway.rs#create_thread", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "not_implemented" +note = "The `create_topic` command from `GatewayAdapter::create_thread` is not handled by Teams `handle_reply` (falls through to plain send). Inbound events set `thread_id: None` ('Teams conversations don't have sub-threads in the same way'). Core `create_thread` falls back to the same channel on timeout anyway." +source = [ + "crates/openab-core/src/gateway.rs#create_thread", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", +] +pr = "" [[openab_features]] feature = "media_inbound" -status = "not_implemented" -note = "Webhook only reads `activity.text`; attachments are neither parsed nor forwarded (`mentions` passed as empty `vec![]`; no attachment extraction). The `ChannelAccount`/`Activity` DTOs don't even model `attachments`." -source = ["crates/openab-gateway/src/adapters/teams.rs#Activity"] -pr = "" +status = "not_implemented" +note = "Webhook only reads `activity.text`; attachments are neither parsed nor forwarded (`mentions` passed as empty `vec![]`; no attachment extraction). The `ChannelAccount`/`Activity` DTOs don't even model `attachments`." +source = ["crates/openab-gateway/src/adapters/teams.rs#Activity"] +pr = "" [[openab_features]] feature = "voice_stt" -status = "n_a" -note = "No voice-note ingestion path in the adapter; not applicable." -source = ["crates/openab-gateway/src/adapters/teams.rs"] -pr = "" +status = "n_a" +note = "No voice-note ingestion path in the adapter; not applicable." +source = ["crates/openab-gateway/src/adapters/teams.rs"] +pr = "" [[openab_features]] feature = "trust_gate" -status = "implemented" -note = "Two layers: platform-level `check_tenant` (optional `allowed_tenants` allowlist) at ingress, plus core's shared `gate_incoming` (L2 scope + L3 identity) applied to all gateway events in `process_gateway_event`." -source = ["crates/openab-gateway/src/adapters/teams.rs#check_tenant", "crates/openab-core/src/adapter.rs#gate_incoming"] -pr = "" +status = "implemented" +note = "Two layers: platform-level `check_tenant` (optional `allowed_tenants` allowlist) at ingress, plus core's shared `gate_incoming` (L2 scope + L3 identity) applied to all gateway events in `process_gateway_event`." +source = [ + "crates/openab-gateway/src/adapters/teams.rs#check_tenant", + "crates/openab-core/src/adapter.rs#gate_incoming", +] +pr = "" [[openab_features]] feature = "deny_echo" -status = "implemented" -note = "On `DenyIdentity`, core echoes the sender their ID (throttled via `echo_allowed`). Delivered through the gateway send path, so subject to the same reply constraints as normal sends." -source = ["crates/openab-core/src/gateway.rs#echo_allowed"] -pr = "" +status = "implemented" +note = "On `DenyIdentity`, core echoes the sender their ID (throttled via `echo_allowed`). Delivered through the gateway send path, so subject to the same reply constraints as normal sends." +source = ["crates/openab-core/src/gateway.rs#echo_allowed"] +pr = "" [[openab_features]] feature = "mention_gating" -status = "partial" -note = "Core `should_skip_event` enforces @mention gating in groups when `bot_username` is set — but the Teams webhook forwards `mentions: vec![]` ('@mentions parsing deferred to future PR'), so gating can't match a Teams mention. It also only fires for `channel_type` `group`/`supergroup`; Teams sends `groupChat`/`channel`, which don't match. Teams itself only delivers @mentioned messages in channels, which mitigates this at the platform layer." -source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/teams.rs#handle_reply"] -pr = "" +status = "partial" +note = "Core `should_skip_event` enforces @mention gating in groups when `bot_username` is set — but the Teams webhook forwards `mentions: vec![]` ('@mentions parsing deferred to future PR'), so gating can't match a Teams mention. It also only fires for `channel_type` `group`/`supergroup`; Teams sends `groupChat`/`channel`, which don't match. Teams itself only delivers @mentioned messages in channels, which mitigates this at the platform layer." +source = [ + "crates/openab-core/src/gateway.rs#should_skip_event", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", +] +pr = "" [[openab_features]] feature = "slash_commands" -status = "implemented" -note = "`/reset` and `/cancel` are parsed from message text by core's gateway loops (WS path + unified `process_gateway_event`); no native Teams slash protocol needed." -source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] -pr = "" +status = "implemented" +note = "`/reset` and `/cancel` are parsed from message text by core's gateway loops (WS path + unified `process_gateway_event`); no native Teams slash protocol needed." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" [[openab_features]] feature = "multibot" -status = "partial" -note = "Core supports multi-bot suppression of streaming (`use_streaming(other_bot_present)`); moot on Teams because streaming edits don't reach it and Teams doesn't deliver other bots' messages anyway." -source = ["crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-core/src/adapter.rs#use_streaming"] -pr = "" +status = "partial" +note = "Core supports multi-bot suppression of streaming (`use_streaming(other_bot_present)`); moot on Teams because streaming edits don't reach it and Teams doesn't deliver other bots' messages anyway." +source = [ + "crates/openab-core/src/gateway.rs#use_streaming", + "crates/openab-core/src/adapter.rs#use_streaming", +] +pr = "" [[openab_features]] feature = "group_routing" -status = "implemented" -note = "Session keyed by `conversation.id` (+ `conversation_type`); `serviceUrl` cached per conversation for reply routing, refreshed (timestamp) on each reply, with a periodic TTL cleanup task in the gateway." -source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply", "crates/openab-gateway/src/lib.rs"] -pr = "" +status = "implemented" +note = "Session keyed by `conversation.id` (+ `conversation_type`); `serviceUrl` cached per conversation for reply routing, refreshed (timestamp) on each reply, with a periodic TTL cleanup task in the gateway." +source = [ + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", + "crates/openab-gateway/src/lib.rs", +] +pr = "" [[openab_features]] feature = "cron_dispatch" -status = "not_implemented" -note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." -source = [] -pr = "" +status = "not_implemented" +note = "Not wired for scheduled cron dispatch: `VALID_PLATFORMS` (cron.rs) covers only discord/slack/telegram and no adapter is registered for this platform in `cron_adapters`, so `cronjob.toml` jobs targeting it are rejected at startup by `validate_cronjobs`." +source = [] +pr = "" # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ [[quirks]] -date = "2026-07-04" -title = "serviceUrl is per-conversation and must be cached/refreshed" -note = "Teams replies are POSTed to a `serviceUrl` that arrives on each inbound activity and can change over time. The adapter caches `conversation.id → (serviceUrl, timestamp)` on ingress and refreshes the timestamp on every reply to avoid TTL expiry mid-conversation; a background task in the gateway evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies)." -kind = "openab_decision" +date = "2026-07-04" +title = "serviceUrl is per-conversation and must be cached/refreshed" +note = "Teams replies are POSTed to a `serviceUrl` that arrives on each inbound activity and can change over time. The adapter caches `conversation.id → (serviceUrl, timestamp)` on ingress and refreshes the timestamp on every reply to avoid TTL expiry mid-conversation; a background task in the gateway evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies)." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" [[quirks]] -date = "2026-07-04" -title = "Sender identity: from.id vs aadObjectId" -note = "Verified: the adapter uses `activity.from.id` (the `29:1abc...` Bot Framework/Teams user id) as `SenderInfo.id`, not `from.aadObjectId`. `aadObjectId` (Entra object id) is deserialized but unused — it can be null for guests/anonymous users, whereas `from.id` is always present and stable, so it's the correct trust-gate key. Tenant is resolved with fallbacks: top-level `tenant.id` → `channelData.tenant.id` → `conversation.tenantId` because Teams places it differently for personal vs channel webhooks (tests pin this)." -kind = "openab_decision" +date = "2026-07-04" +title = "Sender identity: from.id vs aadObjectId" +note = "Verified: the adapter uses `activity.from.id` (the `29:1abc...` Bot Framework/Teams user id) as `SenderInfo.id`, not `from.aadObjectId`. `aadObjectId` (Entra object id) is deserialized but unused — it can be null for guests/anonymous users, whereas `from.id` is always present and stable, so it's the correct trust-gate key. Tenant is resolved with fallbacks: top-level `tenant.id` → `channelData.tenant.id` → `conversation.tenantId` because Teams places it differently for personal vs channel webhooks (tests pin this)." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" [[quirks]] -date = "2026-07-04" -title = "The reply/quote target is dropped" -note = "`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id) and maps it to `replyToId`. It never reads `quote_message_id`, so a caller asking for a visual reply/quote gets a plain reply-target `replyToId` at best and no visual quote. This is a distinct gap from the write-side commands." -kind = "openab_decision" +date = "2026-07-04" +title = "The reply/quote target is dropped" +note = "`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id) and maps it to `replyToId`. It never reads `quote_message_id`, so a caller asking for a visual reply/quote gets a plain reply-target `replyToId` at best and no visual quote. This is a distinct gap from the write-side commands." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" [[quirks]] -date = "2026-07-04" -title = "Auth is heavier than most adapters (endorsements + serviceUrl claim)" -note = "JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the signing JWK endorses the activity's `channelId` and (B1) that the token's `serviceurl` claim equals the activity's `serviceUrl` — binding the token to the specific channel/service origin. JWKS keys are cached (1 h TTL) with a force-refresh-on-cache-miss path for Microsoft key rotation. The activity body is parsed before JWT auth (Bot Framework needs `serviceUrl`/`channelId` from the body to validate) — this is why the pre-auth body is capped at 256 KB." -kind = "intrinsic" +date = "2026-07-04" +title = "Auth is heavier than most adapters (endorsements + serviceUrl claim)" +note = "JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the signing JWK endorses the activity's `channelId` and (B1) that the token's `serviceurl` claim equals the activity's `serviceUrl` — binding the token to the specific channel/service origin. JWKS keys are cached (1 h TTL) with a force-refresh-on-cache-miss path for Microsoft key rotation. The activity body is parsed before JWT auth (Bot Framework needs `serviceUrl`/`channelId` from the body to validate) — this is why the pre-auth body is capped at 256 KB." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" [[quirks]] -date = "2026-07-04" -title = "Write-side commands are largely unimplemented" -note = "`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop) — everything non-reaction is treated as a plain send. This means streaming (post+edit), thread creation, and message edit/delete are effectively no-ops or mis-sends on Teams today, despite the platform supporting all of them (and despite `update_activity` existing as dead code). This is the main gap for a future PR." -kind = "openab_decision" +date = "2026-07-04" +title = "Write-side commands are largely unimplemented" +note = "`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop) — everything non-reaction is treated as a plain send. This means streaming (post+edit), thread creation, and message edit/delete are effectively no-ops or mis-sends on Teams today, despite the platform supporting all of them (and despite `update_activity` existing as dead code). This is the main gap for a future PR." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" [[quirks]] -date = "2026-07-04" -title = "Bot message budget is ~100 KB UTF-16" -note = "Bot message budget is ~100 KB UTF-16 (text + image links + mentions + reactions, excl. base64 images); recommend ≤80 KB, over-limit returns `413 RequestEntityTooLarge` / `MessageSizeTooBig`." -kind = "intrinsic" +date = "2026-07-04" +title = "Bot message budget is ~100 KB UTF-16" +note = "Bot message budget is ~100 KB UTF-16 (text + image links + mentions + reactions, excl. base64 images); recommend ≤80 KB, over-limit returns `413 RequestEntityTooLarge` / `MessageSizeTooBig`." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages" [[quirks]] -date = "2026-07-04" -title = "Send rate limits" -note = "Per-bot-per-thread send limits 7/1s, 8/2s, 60/30s, 1800/3600s; global 50 RPS per app per tenant; throttle → `429` (retry `412`/`502`/`504` too), use exponential backoff." -kind = "intrinsic" +date = "2026-07-04" +title = "Send rate limits" +note = "Per-bot-per-thread send limits 7/1s, 8/2s, 60/30s, 1800/3600s; global 50 RPS per app per tenant; throttle → `429` (retry `412`/`502`/`504` too), use exponential backoff." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit" [[quirks]] -date = "2026-07-04" -title = "suggestedActions constraints" -note = "`suggestedActions` support `imBack` only, ≤6 buttons, one-on-one chats only, and not alongside attachments (any conversation type)." -kind = "intrinsic" +date = "2026-07-04" +title = "suggestedActions constraints" +note = "`suggestedActions` support `imBack` only, ≤6 buttons, one-on-one chats only, and not alongside attachments (any conversation type)." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [[quirks]] -date = "2026-07-04" -title = "Outbound picture limits" -note = "Outbound pictures ≤1024×1024 px, ≤1 MB, PNG/JPEG/GIF; animated GIF unsupported; Markdown inline image defaults to 256×256." -kind = "intrinsic" +date = "2026-07-04" +title = "Outbound picture limits" +note = "Outbound pictures ≤1024×1024 px, ≤1 MB, PNG/JPEG/GIF; animated GIF unsupported; Markdown inline image defaults to 256×256." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [[quirks]] -date = "2026-07-04" -title = "Reactions supported by platform, unused by OpenAB" -note = "Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams." -kind = "intrinsic" +date = "2026-07-04" +title = "Reactions supported by platform, unused by OpenAB" +note = "Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" [[quirks]] -date = "2026-07-04" -title = "Bot can edit/delete only its own messages" -note = "Bot can edit (`PUT .../activities/{activityId}`) and delete (`DELETE .../activities/{activityId}`) its own messages but never user messages." -kind = "intrinsic" +date = "2026-07-04" +title = "Bot can edit/delete only its own messages" +note = "Bot can edit (`PUT .../activities/{activityId}`) and delete (`DELETE .../activities/{activityId}`) its own messages but never user messages." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages" [[quirks]] -date = "2026-07-04" -title = "No native bot slash-command protocol" -note = "No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed." -kind = "intrinsic" +date = "2026-07-04" +title = "No native bot slash-command protocol" +note = "No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu" [[quirks]] -date = "2026-07-04" -title = "Bot only receives @mentioned messages in channels/group chats" -note = "In channels/group chats a bot only receives messages where it is @mentioned (unless RSC); mentions live in `entities[]` (`type: mention`, `mentioned.id`/`.name`), not the text markup." -kind = "intrinsic" +date = "2026-07-04" +title = "Bot only receives @mentioned messages in channels/group chats" +note = "In channels/group chats a bot only receives messages where it is @mentioned (unless RSC); mentions live in `entities[]` (`type: mention`, `mentioned.id`/`.name`), not the text markup." +kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations" [[quirks]] -date = "2026-07-04" -title = "Verified sender id = activity.from.id" -note = "Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter forwards `from.id` as trust-gate key." -kind = "openab_decision" +date = "2026-07-04" +title = "Verified sender id = activity.from.id" +note = "Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter forwards `from.id` as trust-gate key." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] -date = "2026-07-04" -title = "handle_reply only plain sends + drops reactions" -note = "Teams `handle_reply` only handles plain sends + drops reactions; `edit_message`/`delete_message`/`create_topic` are undispatched (fall through to `send_activity`), `quote_message_id` is ignored, and inbound attachments/mentions are not parsed — main gaps for a follow-up PR." -kind = "openab_decision" +date = "2026-07-04" +title = "handle_reply only plain sends + drops reactions" +note = "Teams `handle_reply` only handles plain sends + drops reactions; `edit_message`/`delete_message`/`create_topic` are undispatched (fall through to `send_activity`), `quote_message_id` is ignored, and inbound attachments/mentions are not parsed — main gaps for a follow-up PR." +kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" diff --git a/src/main.rs b/src/main.rs index a2ee786ac..e82a00c1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1105,6 +1105,7 @@ async fn main() -> anyhow::Result<()> { streaming: gw_cfg.streaming, streaming_placeholder: gw_cfg.streaming_placeholder, telegram_rich_messages: gw_cfg.telegram_rich_messages, + gateway_ack_timeout_secs: gw_cfg.gateway_ack_timeout_secs, stt: cfg.stt.clone(), }; let gw_router = router.clone(); diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index c943f4d0d..0cc443a82 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -3,7 +3,10 @@ use anyhow::Result; use async_trait::async_trait; -use openab_core::adapter::{ChannelRef, ChatAdapter, MessageRef}; +use openab_core::adapter::{ + AdapterCapabilities, ChannelRef, ChatAdapter, MessageLimit, MessageRef, StatusBackend, + StreamingMode, +}; use openab_gateway::schema::{Content, GatewayReply, ReplyChannel}; use openab_gateway::AppState; use std::collections::HashMap; @@ -152,7 +155,68 @@ impl ChatAdapter for UnifiedGatewayAdapter { } fn message_limit(&self) -> usize { - 4096 // conservative limit across platforms + 4096 // conservative legacy limit across platforms + } + + fn capabilities(&self, platform: &str) -> AdapterCapabilities { + let telegram_streaming = self + .gw_state + .telegram_streaming + .unwrap_or(self.gw_state.telegram_rich_messages); + let (can_edit, can_delete, streaming_mode, status_backend) = match platform { + "telegram" => ( + self.gw_state.telegram_rich_messages, + false, + if telegram_streaming && self.gw_state.telegram_rich_messages { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }, + StatusBackend::Reactions, + ), + // Unified mode currently has no per-platform streaming switch for + // these adapters. Keep them send-once rather than inheriting the + // unrelated Telegram setting. + "feishu" => ( + true, + true, + StreamingMode::Disabled, + StatusBackend::Reactions, + ), + "googlechat" => ( + true, + false, + StreamingMode::Disabled, + StatusBackend::Reactions, + ), + "wecom" => (false, false, StreamingMode::Disabled, StatusBackend::None), + "teams" | "line" | "lineworks" | "acp" => { + (false, false, StreamingMode::Disabled, StatusBackend::None) + } + _ => ( + false, + false, + StreamingMode::Disabled, + StatusBackend::Reactions, + ), + }; + AdapterCapabilities { + send_ack: false, + edit_ack: false, + delete_ack: false, + can_edit, + can_delete, + streaming_mode, + show_streaming_placeholder: !(platform == "telegram" + && self.gw_state.telegram_rich_messages), + message_limit: match platform { + "acp" => MessageLimit::Unlimited, + "lineworks" => MessageLimit::Characters { max: 2000 }, + "wecom" => MessageLimit::Characters { max: 2048 }, + _ => MessageLimit::Characters { max: 4096 }, + }, + status_backend, + } } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { @@ -247,3 +311,46 @@ impl ChatAdapter for UnifiedGatewayAdapter { platform == "telegram" && self.gw_state.telegram_rich_messages } } + +#[cfg(test)] +mod tests { + use super::*; + + fn adapter_with_telegram_streaming(streaming: bool) -> UnifiedGatewayAdapter { + let (event_tx, _event_rx) = tokio::sync::broadcast::channel(4); + let mut state = AppState::test_default(event_tx); + state.telegram_streaming = Some(streaming); + state.telegram_rich_messages = streaming; + UnifiedGatewayAdapter::new(Arc::new(state)) + } + + #[test] + fn teams_capabilities_do_not_inherit_telegram_streaming() { + let adapter = adapter_with_telegram_streaming(true); + let capabilities = adapter.capabilities("teams"); + assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); + assert!(!capabilities.can_edit); + assert_eq!(capabilities.status_backend, StatusBackend::None); + } + + #[test] + fn telegram_capabilities_follow_telegram_streaming() { + let adapter = adapter_with_telegram_streaming(true); + let capabilities = adapter.capabilities("telegram"); + assert_eq!(capabilities.streaming_mode, StreamingMode::Edit); + assert!(!capabilities.show_streaming_placeholder); + } + + #[test] + fn telegram_without_rich_drafts_is_send_once() { + let (event_tx, _event_rx) = tokio::sync::broadcast::channel(4); + let mut state = AppState::test_default(event_tx); + state.telegram_streaming = Some(true); + state.telegram_rich_messages = false; + let adapter = UnifiedGatewayAdapter::new(Arc::new(state)); + + let capabilities = adapter.capabilities("telegram"); + assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); + assert!(!capabilities.can_edit); + } +} From e59e3afa9386c3c75d0e378ceb169dc0d636e87a Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:55:36 +0800 Subject: [PATCH 05/16] fix(teams): reject unsupported gateway commands --- crates/openab-gateway/src/adapters/teams.rs | 139 +++++++++++++++++--- crates/openab-gateway/src/lib.rs | 11 +- docs/platforms/schema/teams.toml | 8 +- src/unified_adapter.rs | 11 +- 4 files changed, 144 insertions(+), 25 deletions(-) diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 0ee438d11..e4ea14c0b 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -5,7 +5,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::Deserialize; use std::sync::Arc; use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; +use tracing::{debug, info, warn}; // --- Bot Framework activity types --- @@ -572,12 +572,18 @@ pub async fn handle_reply( 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; +) -> anyhow::Result> { + // Fail closed for commands the Teams adapter does not implement. Falling + // through to `send_activity` would turn edit/delete/topic commands into new + // messages, producing duplicate or misleading output. Reaction commands + // remain an intentional no-op until a Teams status backend is implemented. + match reply.command.as_deref() { + None => {} + Some("add_reaction" | "remove_reaction") => { + debug!(command = ?reply.command.as_deref(), "teams: ignoring unsupported reaction command"); + return Ok(None); + } + Some(command) => anyhow::bail!("unsupported Teams command: {command}"), } let service_url = { @@ -588,10 +594,10 @@ pub async fn handle_reply( *ts = std::time::Instant::now(); url.clone() } - None => { - error!(conversation = %reply.channel.id, "teams: no service_url for conversation"); - return; - } + None => anyhow::bail!( + "no Teams service_url for conversation {}", + reply.channel.id + ), } }; @@ -602,23 +608,25 @@ pub async fn handle_reply( }; info!(conversation = %reply.channel.id, "gateway → teams"); - match teams + let id = 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"), - } + .await?; + debug!(activity_id = %id, "teams activity sent"); + Ok(Some(id)) } #[cfg(test)] mod tests { use super::*; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; // --- ensure_trailing_slash --- @@ -664,6 +672,26 @@ mod tests { }) } + fn make_reply(command: Option<&str>) -> GatewayReply { + GatewayReply { + 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, + } + } + fn make_activity_with_tenant(tenant_id: Option<&str>) -> Activity { Activity { activity_type: "message".into(), @@ -859,6 +887,83 @@ mod tests { assert!(result.is_err()); } + // --- reply command dispatch --- + + #[tokio::test] + async fn unsupported_commands_never_fall_through_to_send_activity() { + 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(config); + let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( + "conversation-1".to_string(), + (server.uri(), std::time::Instant::now()), + )])); + + for command in ["add_reaction", "remove_reaction"] { + let outcome = handle_reply(&make_reply(Some(command)), &adapter, &service_urls) + .await + .unwrap(); + assert_eq!(outcome, None, "reaction command {command} should be a no-op"); + } + + for command in [ + "create_topic", + "edit_message", + "delete_message", + "future_unknown_command", + ] { + let error = handle_reply(&make_reply(Some(command)), &adapter, &service_urls) + .await + .unwrap_err(); + assert!( + error.to_string().contains(command), + "error should identify unsupported command {command}" + ); + } + } + + #[tokio::test] + async fn commandless_reply_still_sends_one_activity() { + 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!({"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(config); + let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( + "conversation-1".to_string(), + (server.uri(), std::time::Instant::now()), + )])); + + let message_id = handle_reply(&make_reply(None), &adapter, &service_urls) + .await + .unwrap(); + assert_eq!(message_id.as_deref(), Some("activity-1")); + } + // --- TeamsConfig::from_env --- #[test] diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 517780961..007d85880 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -1245,12 +1245,19 @@ 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 let Err(e) = adapters::teams::handle_reply( &reply, teams, &state_for_recv.teams_service_urls, ) - .await; + .await + { + tracing::error!( + error = %e, + command = ?reply.command.as_deref(), + "teams reply rejected" + ); + } } else { warn!("reply for teams but adapter not configured"); } diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index f298ef05c..efdefeffb 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -139,7 +139,7 @@ pr = "" [[openab_features]] feature = "streaming" status = "workaround" -note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). But the Teams gateway never dispatches `edit_message`, so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." +note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). The Teams gateway fail-closed rejects `edit_message`, so streaming edits don't reach Teams and cannot create duplicate messages; delivery is effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." source = [ "crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-gateway/src/adapters/teams.rs#update_activity", @@ -159,7 +159,7 @@ pr = "" [[openab_features]] feature = "edit_message" status = "not_implemented" -note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) AND `handle_reply` has no `edit_message` branch — it falls through to `send_activity`, posting the new text as a fresh message." +note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) and `handle_reply` fail-closed rejects the unsupported command. No edit occurs, but it also cannot fall through to `send_activity` and post duplicate text." source = [ "crates/openab-core/src/gateway.rs#edit_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply", @@ -169,7 +169,7 @@ pr = "" [[openab_features]] feature = "delete_message" status = "not_implemented" -note = "The `delete_message` command is not handled in `handle_reply`; it falls through to `send_activity`. Platform supports DELETE, but the adapter never calls it." +note = "The `delete_message` command is fail-closed rejected by `handle_reply`. Platform supports DELETE, but the adapter does not call it yet; importantly, the command cannot fall through to `send_activity`." source = [ "crates/openab-core/src/gateway.rs#delete_message", "crates/openab-gateway/src/adapters/teams.rs#handle_reply", @@ -186,7 +186,7 @@ pr = "" [[openab_features]] feature = "threads_topics" status = "not_implemented" -note = "The `create_topic` command from `GatewayAdapter::create_thread` is not handled by Teams `handle_reply` (falls through to plain send). Inbound events set `thread_id: None` ('Teams conversations don't have sub-threads in the same way'). Core `create_thread` falls back to the same channel on timeout anyway." +note = "The `create_topic` command from `GatewayAdapter::create_thread` is fail-closed rejected by Teams `handle_reply` and cannot fall through to plain send. Inbound events set `thread_id: None` ('Teams conversations don't have sub-threads in the same way'). Core `create_thread` falls back to the same channel on timeout anyway." source = [ "crates/openab-core/src/gateway.rs#create_thread", "crates/openab-gateway/src/adapters/teams.rs#handle_reply", diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index 0cc443a82..96a19c3d5 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -100,12 +100,19 @@ impl UnifiedGatewayAdapter { #[cfg(feature = "teams")] "teams" => { if let Some(ref teams) = self.gw_state.teams { - openab_gateway::adapters::teams::handle_reply( + if let Err(e) = openab_gateway::adapters::teams::handle_reply( reply, teams, &self.gw_state.teams_service_urls, ) - .await; + .await + { + tracing::error!( + error = %e, + command = ?reply.command.as_deref(), + "teams reply rejected" + ); + } } } #[cfg(feature = "acp")] From 2e10c389ca526665ea353a78f02ec39d7ff078e8 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:55:53 +0800 Subject: [PATCH 06/16] fix(teams)!: harden Bot Connector transport --- crates/openab-gateway/src/adapters/teams.rs | 1060 ++++++++++++++++--- docs/config-reference.md | 21 +- docs/msteams-enterprise.md | 23 +- docs/msteams-selfhosted.md | 10 +- docs/platforms/schema/teams.toml | 19 +- 5 files changed, 983 insertions(+), 150 deletions(-) diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index e4ea14c0b..1b03ca948 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -4,8 +4,9 @@ use axum::http::{HeaderMap, StatusCode}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::Deserialize; use std::sync::Arc; -use tokio::sync::RwLock; -use tracing::{debug, info, warn}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; +use tracing::{debug, error, info, warn}; // --- Bot Framework activity types --- @@ -110,7 +111,19 @@ 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 --- @@ -159,38 +172,85 @@ pub struct TeamsAdapter { config: TeamsConfig, 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<()>, + allow_non_public_endpoints: 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); +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_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"; impl TeamsAdapter { pub fn new(config: TeamsConfig) -> Self { + Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), false) + } + + fn with_client( + config: TeamsConfig, + client: reqwest::Client, + allow_non_public_endpoints: bool, + ) -> Self { Self { config, - client: reqwest::Client::new(), + client, 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(()), + allow_non_public_endpoints, } } - /// Get a valid OAuth bearer token, refreshing if needed. + #[cfg(test)] + fn new_for_test(config: TeamsConfig) -> Self { + Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), true) + } + + #[cfg(test)] + fn new_for_test_with_timeout(config: TeamsConfig, request_timeout: Duration) -> Self { + Self::with_client(config, build_http_client(request_timeout), true) + } + + 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 { - // Check cache - { - let cache = self.token_cache.read().await; - if let Some(ref cached) = *cache { - if cached.expires_at > std::time::Instant::now() + TOKEN_REFRESH_MARGIN { - return Ok(cached.token.clone()); - } - } + if let Some(token) = self.cached_token().await { + return Ok(token); } - // Fetch new token - let resp: TokenResponse = self + let _refresh_guard = self.token_refresh_lock.lock().await; + if let Some(token) = self.cached_token().await { + return Ok(token); + } + + 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 +258,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 config: OpenIdConfig = self + let _refresh_guard = self.openid_refresh_lock.lock().await; + if let Some(cached) = self.cached_openid().await { + return Ok(cached.jwks_uri); + } + + 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 +410,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 +433,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 +453,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 +461,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,7 +486,30 @@ 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)) + } + + 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 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. @@ -340,12 +520,8 @@ impl TeamsAdapter { text: &str, reply_to_id: Option<&str>, ) -> anyhow::Result { + let url = self.connector_url(service_url, conversation_id, None)?; let token = self.get_token().await?; - let url = format!( - "{}v3/conversations/{}/activities", - ensure_trailing_slash(service_url), - conversation_id - ); let mut body = serde_json::json!({ "type": "message", @@ -357,22 +533,26 @@ impl TeamsAdapter { body["replyToId"] = serde_json::Value::String(id.to_string()); } - let resp = self + let response = self .client - .post(&url) + .post(url) .bearer_auth(&token) .json(&body) .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - anyhow::bail!("Bot Framework API error {status}: {body}"); - } - - let result: serde_json::Value = resp.json().await?; - Ok(result["id"].as_str().unwrap_or("").to_string()) + .await + .map_err(|error| safe_request_error("Bot Framework send", &error))?; + let response = + require_http_success(response, "Bot Framework send", &[token.as_str()]).await?; + let result: serde_json::Value = response + .json() + .await + .map_err(|_| anyhow::anyhow!("Bot Framework send response was not valid JSON"))?; + result + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("Bot Framework send response missing activity id")) } /// Edit an existing activity (for streaming updates). @@ -383,43 +563,346 @@ impl TeamsAdapter { activity_id: &str, text: &str, ) -> anyhow::Result<()> { + let url = self.connector_url(service_url, conversation_id, Some(activity_id))?; let token = self.get_token().await?; - let url = format!( - "{}v3/conversations/{}/activities/{}", - ensure_trailing_slash(service_url), - conversation_id, - activity_id - ); - let body = serde_json::json!({ "type": "message", "from": { "id": &self.config.app_id }, "text": text, + "textFormat": "markdown", }); - let resp = self + let response = self .client - .put(&url) + .put(url) .bearer_auth(&token) .json(&body) .send() - .await?; + .await + .map_err(|error| safe_request_error("Bot Framework update", &error))?; + require_http_success(response, "Bot Framework update", &[token.as_str()]).await?; + Ok(()) + } +} - 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 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(); } - Ok(()) + 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 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 ensure_trailing_slash(url: &str) -> String { - if url.ends_with('/') { - url.to_string() +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 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 { - format!("{url}/") + "request failed" + }; + anyhow::anyhow!("{operation} {kind}") +} + +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}") +} + +async fn read_bounded_error_body( + mut response: reqwest::Response, + sensitive_values: &[&str], +) -> String { + 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 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]"); + } + redacted +} + +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); } // --- Webhook handler --- @@ -520,11 +1003,20 @@ pub async fn webhook( .unwrap_or("Unknown"); let activity_id = activity.id.as_deref().unwrap_or(""); - // B3: Guard against empty service_url — replies will fail without it + // B3: Guard against an absent or unsafe service URL before persisting a + // reply route. JWT validation binds this value to Microsoft; the public- + // cloud policy additionally prevents credential-bearing SSRF. if service_url.is_empty() { warn!("teams: activity missing service_url, cannot route replies"); return StatusCode::OK; } + 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 event = GatewayEvent::new( "teams", @@ -547,16 +1039,22 @@ pub async fn webhook( // 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()), + (validated_service_url.to_string(), Instant::now()), ); - let json = serde_json::to_string(&event).unwrap(); + 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; + } + }; let tenant_id = activity.resolved_tenant_id().unwrap_or(""); info!( conversation = conversation_id, sender = sender_name, tenant = tenant_id, - service_url = service_url, + service_host = validated_service_url.host_str().unwrap_or("unknown"), "teams → gateway" ); let _ = state.event_tx.send(json); @@ -628,27 +1126,101 @@ mod tests { Mock, MockServer, ResponseTemplate, }; - // --- ensure_trailing_slash --- + // --- Bot Connector URL and error hardening --- #[test] - fn trailing_slash_adds_when_missing() { + fn connector_url_encodes_segments_and_preserves_service_path() { + let url = connector_url( + "https://smba.trafficmanager.net/teams/", + "a/b?c", + Some("message id/%"), + false, + ) + .unwrap(); assert_eq!( - ensure_trailing_slash("https://example.com"), - "https://example.com/" + url.as_str(), + "https://smba.trafficmanager.net/teams/v3/conversations/a%2Fb%3Fc/activities/message%20id%2F%25" ); } #[test] - fn trailing_slash_keeps_when_present() { - assert_eq!( - ensure_trailing_slash("https://example.com/"), - "https://example.com/" - ); + 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 trailing_slash_empty_string() { - assert_eq!(ensure_trailing_slash(""), "/"); + 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 --- @@ -663,6 +1235,13 @@ mod tests { } } + 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); @@ -887,6 +1466,239 @@ mod tests { assert!(result.is_err()); } + // --- transport concurrency and HTTP policy --- + + #[tokio::test] + async fn concurrent_oauth_callers_share_one_refresh() { + 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.unwrap(), "singleflight-token"); + assert_eq!(second.unwrap(), "singleflight-token"); + } + + #[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() { + 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.unwrap().keys.len(), 1); + assert_eq!(second.unwrap().keys.len(), 1); + } + + #[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_rejected() { + 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 error = adapter + .send_activity(&server.uri(), "conversation-1", "hello", None) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("missing activity id")); + } + + #[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 error = adapter + .send_activity(&server.uri(), "conversation-1", "hello", None) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("500")); + assert!(error.contains("[truncated]")); + assert!(!error.contains("leaked-token")); + assert!(!error.contains("test-token")); + assert!(!error.contains("sensitive.example")); + assert!(error.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 error = adapter + .send_activity(&server.uri(), "conversation-1", "hello", None) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("timed out")); + assert!(!error.contains(&server.uri())); + } + // --- reply command dispatch --- #[tokio::test] @@ -900,7 +1712,7 @@ mod tests { let mut config = make_config(vec![]); config.oauth_endpoint = format!("{}/token", server.uri()); - let adapter = TeamsAdapter::new(config); + let adapter = TeamsAdapter::new_for_test(config); let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( "conversation-1".to_string(), (server.uri(), std::time::Instant::now()), @@ -952,7 +1764,7 @@ mod tests { let mut config = make_config(vec![]); config.oauth_endpoint = format!("{}/token", server.uri()); - let adapter = TeamsAdapter::new(config); + let adapter = TeamsAdapter::new_for_test(config); let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( "conversation-1".to_string(), (server.uri(), std::time::Instant::now()), diff --git a/docs/config-reference.md b/docs/config-reference.md index 17913b1fc..fecdbe186 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -237,13 +237,15 @@ Full first-class Google Chat section (config-first parity, #1379) — credential Full first-class Teams section (config-first parity, #1380) — credentials, connection, and L3 identity trust. Each field resolves: config → `TEAMS_*` env → default. `app_id` + `app_secret` are mandatory (after env fallback); an incomplete section disables the adapter. +> ⚠️ **M0 cloud-profile restriction:** Teams transport supports Microsoft commercial public cloud only. The adapter rejects non-HTTPS endpoints, userinfo, non-standard ports, sovereign-cloud hosts, custom proxy hosts, and service URLs outside `smba.trafficmanager.net`. Existing sovereign-cloud or proxy deployments must remain on an earlier release until an explicit cloud profile is available. + | Key | Type | Default | Description | |-----|------|---------|-------------| | `app_id` | string | — | Azure AD app (bot) ID. Env: `TEAMS_APP_ID`. | | `app_secret` | string | — | App client secret. Env: `TEAMS_APP_SECRET`. | | `allowed_tenants` | string[] | `[]` (all) | Restrict to tenant IDs. Env: `TEAMS_ALLOWED_TENANTS`. | -| `oauth_endpoint` | string | Bot Framework | Env: `TEAMS_OAUTH_ENDPOINT`. | -| `openid_metadata` | string | Bot Framework | Env: `TEAMS_OPENID_METADATA`. | +| `oauth_endpoint` | string | Bot Framework | HTTPS endpoint on `login.microsoftonline.com`; use a tenant-specific path for single-tenant bots. Env: `TEAMS_OAUTH_ENDPOINT`. | +| `openid_metadata` | string | Bot Framework | HTTPS metadata endpoint on `login.botframework.com`. Env: `TEAMS_OPENID_METADATA`. | | `webhook_path` | string | `/webhook/teams` | Env: `TEAMS_WEBHOOK_PATH`. | | `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | | `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. | @@ -526,6 +528,7 @@ Secret references. Each key maps to a provider URI. Resolved values are availabl | `` | string | — | URI referencing an external secret. Supported schemes: `aws-sm://`, `exec://`. | **URI formats:** + - `aws-sm://#` — fetch from AWS Secrets Manager, extract JSON field - `exec:// ` — run script with two arguments, read stdout @@ -615,6 +618,7 @@ Keys can be unicode emoji or Discord/GitHub shortcodes (e.g. `:thumbsup:`). Shor ``` **Requirements:** + - Enable the `GUILD_MESSAGE_REACTIONS` intent in the Discord Developer Portal. - Only unicode emoji are supported (custom server emoji are ignored). - The bot's own reactions are always ignored (prevents feedback loops). @@ -652,6 +656,7 @@ web = "~/projects/frontend" | `aliases` | map | `{}` | Key-value map of alias name → path. Users reference with `@` prefix: `[[ws:@openab]]`. Paths starting with `~` expand to `$HOME`. All paths must be within the bot's home directory (security boundary). | **Security:** + - Relative paths are rejected - `~` expands to bot home (`$HOME`) - Paths are canonicalized and must be within bot home subtree @@ -855,6 +860,7 @@ disable_on_success_working_dir = "/workspace/my-project" ``` **Behaviors:** + - Scheduler evaluates expressions once per minute - If a previous execution is still running, the next tick is skipped (no overlap) - Failed executions are logged but do not block other jobs or chat traffic @@ -914,6 +920,7 @@ Key mapping (`values.yaml` → `config.toml`): | `agents..cronjobs[].threadId` | `[[cron.jobs]] thread_id` | > ⚠️ Use `--set-string` (not `--set`) for Discord/Slack IDs to avoid float64 precision loss: +> > ```bash > helm upgrade --install mybot charts/openab \ > --set-string agents.kiro.discord.allowedChannels[0]="1234567890" @@ -950,12 +957,12 @@ When running with `BUILD_MODE=unified`, the binary embeds a webhook server for g | Variable | Default | Description | |----------|---------|-------------| | `GATEWAY_ALLOW_ALL_CHANNELS` | `true` | Accept events from any channel. **Set to `false` in production** and use `GATEWAY_ALLOWED_CHANNELS`. | -| `GATEWAY_ALLOWED_CHANNELS` | _(empty)_ | Comma-separated channel IDs to allow (when `GATEWAY_ALLOW_ALL_CHANNELS=false`) | +| `GATEWAY_ALLOWED_CHANNELS` | *(empty)* | Comma-separated channel IDs to allow (when `GATEWAY_ALLOW_ALL_CHANNELS=false`) | | `GATEWAY_ALLOW_ALL_USERS` | `true` | Accept events from any user. **Set to `false` in production** and use `GATEWAY_ALLOWED_USERS`. | -| `GATEWAY_ALLOWED_USERS` | _(empty)_ | Comma-separated user IDs to allow (when `GATEWAY_ALLOW_ALL_USERS=false`) | +| `GATEWAY_ALLOWED_USERS` | *(empty)* | Comma-separated user IDs to allow (when `GATEWAY_ALLOW_ALL_USERS=false`) | | `GATEWAY_ALLOW_BOT_MESSAGES` | `false` | Allow messages from all bots (for multi-agent scenarios) | -| `GATEWAY_TRUSTED_BOT_IDS` | _(empty)_ | Comma-separated bot IDs to allow even when `GATEWAY_ALLOW_BOT_MESSAGES=false` | -| `GATEWAY_BOT_USERNAME` | _(empty)_ | Bot's username for @mention detection in groups | +| `GATEWAY_TRUSTED_BOT_IDS` | *(empty)* | Comma-separated bot IDs to allow even when `GATEWAY_ALLOW_BOT_MESSAGES=false` | +| `GATEWAY_BOT_USERNAME` | *(empty)* | Bot's username for @mention detection in groups | ### Platform Adapters @@ -967,7 +974,7 @@ Each platform is auto-enabled when its env vars are present: | LINE | `LINE_CHANNEL_SECRET` | `LINE_CHANNEL_ACCESS_TOKEN` | | Feishu | `FEISHU_APP_ID` | `FEISHU_WEBHOOK_PATH` | | Google Chat | `GOOGLE_CHAT_ENABLED=true` | `GOOGLE_CHAT_SA_KEY_JSON`, `GOOGLE_CHAT_SA_KEY_FILE`, `GOOGLE_CHAT_ACCESS_TOKEN`, `GOOGLE_CHAT_AUDIENCE`, `GOOGLE_CHAT_WEBHOOK_PATH` | -| WeCom | `WECOM_CORP_ID` | _(see wecom config)_ | +| WeCom | `WECOM_CORP_ID` | *(see wecom config)* | | Teams | `TEAMS_APP_ID` | `TEAMS_WEBHOOK_PATH` | > ⚠️ **Production checklist**: Set `GATEWAY_ALLOW_ALL_CHANNELS=false` and `GATEWAY_ALLOW_ALL_USERS=false` with explicit allowlists. The defaults are permissive for development convenience. diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 380bb6d67..4ed8377a3 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -63,6 +63,7 @@ After creation, note from the **Overview** page: 1. Go to the Bot resource → **Configuration** 2. Set **Messaging endpoint** to your Kubernetes Ingress URL: + ``` https:///webhook/teams ``` @@ -338,10 +339,12 @@ kubectl apply -f openab-teams-networking.yaml ### Verify Unified Mode 1. Confirm exactly one pod matches the Service selector: + ```bash kubectl get pods \ -l app.kubernetes.io/name=openab,app.kubernetes.io/instance=openab,app.kubernetes.io/component=kiro ``` + 2. Check startup logs with `kubectl logs deployment/openab-kiro` and verify the Teams adapter is listening on `0.0.0.0:8080`. 3. Send a message from an allowed Teams user and confirm a reply. A user or @@ -632,6 +635,7 @@ Web Chat uses Direct Line (`webchat.botframework.com`), which has different auth IT admin has not approved the custom app, or permission policy hasn't propagated. **Fix**: + 1. Verify the app is uploaded in Teams Admin Center → Manage apps 2. Check Permission policies allow the custom app 3. Wait up to 24 hours for policy propagation @@ -652,11 +656,12 @@ one ready endpoint, and the sender matches both `[teams].allowed_tenants` and ### Standalone Gateway receives webhook but no reply in Teams Check Gateway pod logs: + ```bash kubectl logs deployment/openab-gateway --tail=50 ``` -Look for: `teams → gateway` (received) → `gateway → teams` (sent) → `teams activity sent` (success) or `teams send error` (failure). +Look for: `teams → gateway` (received) → `gateway → teams` (sent) → `teams activity sent` (success) or `teams reply rejected` (failure). ### JWT validation failed @@ -676,15 +681,15 @@ kubectl run openab-metadata-check --rm -i --restart=Never \ - **Rotate client secrets** before expiration — set a reminder based on the expiration chosen in Step 1 - **Use a tenant allowlist** in production — configure `[teams].allowed_tenants` in Unified Mode or `TEAMS_ALLOWED_TENANTS` in Standalone Gateway Mode - **Network policies** — start from default-deny and allow cluster DNS plus - the minimum outbound destinations. The Teams adapter needs the configured + the minimum outbound destinations. The M0 public-cloud profile permits the `login.microsoftonline.com` token endpoint, `login.botframework.com` metadata - and its returned JWKS host, and the HTTPS `serviceUrl` host supplied by each - validated Bot Framework activity (commonly `smba.trafficmanager.net`; the - host can vary by region). The OAB/ACP pod also needs the authentication/API - endpoints for the selected agent backend and any model or tool services it - uses. In Unified Mode these rules apply to the OAB pod. In Standalone Gateway - Mode, give the Gateway the Microsoft egress, allow OAB to reach - `openab-gateway:8080`, and give only OAB the agent/model/tool egress. + and JWKS, and validated HTTPS Bot Connector service URLs on + `smba.trafficmanager.net`. Sovereign-cloud and custom proxy hosts are rejected. + The OAB/ACP pod also needs the authentication/API endpoints for the selected + agent backend and any model or tool services it uses. In Unified Mode these + rules apply to the OAB pod. In Standalone Gateway Mode, give the Gateway the + Microsoft egress, allow OAB to reach `openab-gateway:8080`, and give only OAB + the agent/model/tool egress. - **Minimize inbound exposure** — Unified Mode should expose only `/webhook/teams` through the TLS Ingress; in Standalone Gateway Mode, the OAB pod remains private and connects outbound to the Gateway only ## References diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 43cc395da..767f5c547 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -1,6 +1,5 @@ # Microsoft Teams Setup (Self-Hosted) - > **Unified Mode (v0.9.0+):** The OAB binary now embeds the Teams adapter directly. Set `TEAMS_APP_ID` as an env var — no separate gateway container or `[gateway]` config needed. See [Telegram docs](telegram.md#unified-mode-recommended) for the pattern. ### Unified Config (Kiro + Teams) @@ -320,11 +319,13 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt |---|---|---|---| | `TEAMS_APP_ID` | Yes | — | Azure AD application (client) ID | | `TEAMS_APP_SECRET` | Yes | — | Azure AD client secret value | -| `TEAMS_OAUTH_ENDPOINT` | Single tenant: Yes | `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token` | Override for single tenant bots | -| `TEAMS_OPENID_METADATA` | No | `https://login.botframework.com/v1/.well-known/openidconfiguration` | OpenID metadata for JWT validation | +| `TEAMS_OAUTH_ENDPOINT` | Single tenant: Yes | `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token` | HTTPS endpoint on `login.microsoftonline.com`; override the tenant path for single-tenant bots | +| `TEAMS_OPENID_METADATA` | No | `https://login.botframework.com/v1/.well-known/openidconfiguration` | HTTPS metadata endpoint on `login.botframework.com` | | `TEAMS_ALLOWED_TENANTS` | No | (allow all) | Comma-separated tenant IDs | | `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | URL path the gateway listens on | +> ⚠️ **M0 supports Microsoft commercial public cloud only.** Sovereign-cloud endpoints and custom OAuth/OpenID proxy hosts are rejected. Bot Connector replies accept only validated HTTPS service URLs on `smba.trafficmanager.net`; redirects cannot cross origin. + ## Troubleshooting **401 Unauthorized when bot tries to reply** @@ -346,11 +347,12 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt **Webhook returns 200 but no agent response** Check `docker compose logs gateway openab` and look for the trace: + 1. `teams → gateway` (gateway received webhook) 2. `processing message channel_platform=teams` (OAB picked up the event) 3. `sending reply to gateway platform=teams` (OAB sent the reply over WS) 4. `gateway → teams` (gateway calling Bot Framework REST API) -5. `teams activity sent` (success) or `teams send error` (failure) +5. `teams activity sent` (success) or `teams reply rejected` (failure) Whichever step is missing tells you where the break is. diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index efdefeffb..55a0f6fa0 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -300,9 +300,16 @@ kind = "intrinsic" source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" [[quirks]] -date = "2026-07-04" -title = "Write-side commands are largely unimplemented" -note = "`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop) — everything non-reaction is treated as a plain send. This means streaming (post+edit), thread creation, and message edit/delete are effectively no-ops or mis-sends on Teams today, despite the platform supporting all of them (and despite `update_activity` existing as dead code). This is the main gap for a future PR." +date = "2026-08-06" +title = "Bot Connector transport is pinned to public cloud and fails closed" +note = "M0 accepts HTTPS Bot Connector service URLs only on `smba.trafficmanager.net`, public-cloud OAuth/OpenID hosts only, no userinfo/query/fragment/non-standard ports, and no cross-origin redirects. Conversation/activity IDs are encoded as URL path segments. Connect/request timeouts are 5 s/10 s; error bodies are read up to 4 KiB and redacted. OAuth, OpenID metadata, and JWKS refreshes are single-flight." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/teams.rs" + +[[quirks]] +date = "2026-08-06" +title = "Unsupported write-side commands fail closed" +note = "Teams `handle_reply` sends only commandless replies. Reactions are an explicit no-op; `edit_message`, `delete_message`, `create_topic`, and unknown commands return unsupported before route lookup or network I/O, preventing duplicate or misleading plain messages until native handlers are implemented." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" @@ -370,8 +377,8 @@ kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] -date = "2026-07-04" -title = "handle_reply only plain sends + drops reactions" -note = "Teams `handle_reply` only handles plain sends + drops reactions; `edit_message`/`delete_message`/`create_topic` are undispatched (fall through to `send_activity`), `quote_message_id` is ignored, and inbound attachments/mentions are not parsed — main gaps for a follow-up PR." +date = "2026-08-06" +title = "Reply correlation and inbound rich content remain incomplete" +note = "Teams `handle_reply` supports hardened plain sends and rejects unsupported commands, but still maps the OpenAB event ID from `reply_to` to Bot Connector `replyToId`, ignores `quote_message_id`, and does not parse inbound attachments or mentions. Real activity correlation remains a separate implementation and live-tenant validation step." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" From ce8bdf89853a7d154f052c3c603ecd18bbe7fccf Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:56:26 +0800 Subject: [PATCH 07/16] feat(teams): bound ingress routes and deduplicate activities --- config.toml.example | 3 + crates/openab-core/src/config.rs | 98 ++- crates/openab-gateway/src/adapters/mod.rs | 2 + crates/openab-gateway/src/adapters/teams.rs | 737 +++++++++++++++--- .../src/adapters/teams_ingress.rs | 517 ++++++++++++ crates/openab-gateway/src/lib.rs | 77 +- .../tests/config_first_conformance.rs | 3 + docs/config-reference.md | 3 + docs/msteams-enterprise.md | 3 + docs/msteams-selfhosted.md | 6 + docs/platforms/schema/teams.toml | 22 +- src/main.rs | 5 + 12 files changed, 1359 insertions(+), 117 deletions(-) create mode 100644 crates/openab-gateway/src/adapters/teams_ingress.rs diff --git a/config.toml.example b/config.toml.example index c75063b42..d500b394b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -134,6 +134,9 @@ 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 +# dedupe_ttl_secs = 600 # env fallback: TEAMS_DEDUPE_TTL_SECS +# route_ttl_secs = 3600 # env fallback: TEAMS_ROUTE_TTL_SECS +# max_route_entries = 10000 # env fallback: TEAMS_MAX_ROUTE_ENTRIES # allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS # allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) # # env fallback: TEAMS_ALLOWED_USERS (comma-separated) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 84fcb2edc..68328038f 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1322,6 +1322,15 @@ 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 and dedupe caches. Env fallback: + /// `TEAMS_MAX_ROUTE_ENTRIES` (default 10000). + pub max_route_entries: 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, @@ -1339,6 +1348,9 @@ 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 allow_all_users: bool, pub allowed_users: Vec, } @@ -1363,6 +1375,26 @@ 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) + }; ResolvedTeams { app_id: opt_str(&self.app_id, "TEAMS_APP_ID"), app_secret: opt_str(&self.app_secret, "TEAMS_APP_SECRET"), @@ -1376,6 +1408,13 @@ 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, + ), allow_all_users: self.allow_all_users.unwrap_or_else(|| { std::env::var("TEAMS_ALLOW_ALL_USERS") .ok() @@ -2320,6 +2359,17 @@ 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(ref g) = config.gateway { anyhow::ensure!( g.max_buffered_messages > 0, @@ -3044,7 +3094,13 @@ 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", + ] { std::env::remove_var(k); } // --- defaults --- @@ -3054,20 +3110,32 @@ 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); // --- 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"); 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), ..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); // --- empty-string ${} expansion falls through to env --- let cfg = TeamsConfig { @@ -3077,6 +3145,9 @@ 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); // --- trust_config() view --- let cfg = TeamsConfig { @@ -3091,8 +3162,29 @@ 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", + ] { + std::env::remove_var(k); + } + } + + #[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}" + ); + } } /// All `FEISHU_*` env scenarios in ONE test (env is process-global). diff --git a/crates/openab-gateway/src/adapters/mod.rs b/crates/openab-gateway/src/adapters/mod.rs index 105f51081..18b3b6d45 100644 --- a/crates/openab-gateway/src/adapters/mod.rs +++ b/crates/openab-gateway/src/adapters/mod.rs @@ -11,6 +11,8 @@ pub mod googlechat; #[cfg(feature = "wecom")] pub mod wecom; #[cfg(feature = "teams")] +pub(crate) mod teams_ingress; +#[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 1b03ca948..1cc9fc529 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,3 +1,8 @@ +use super::teams_ingress::{ + wait_for_publish, PublishReservation, PublishState, TeamsIngressCleanupStats, + TeamsIngressRegistry, TeamsIngressRoute, TeamsRouteKey, DEFAULT_DEDUPE_TTL_SECS, + DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, +}; use crate::schema::*; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; @@ -11,7 +16,7 @@ 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")] @@ -25,10 +30,11 @@ pub struct Activity { pub text: Option, pub tenant: Option, pub channel_data: Option, + pub reply_to_id: Option, } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelAccount { pub id: Option, @@ -37,7 +43,7 @@ pub struct ChannelAccount { } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConversationAccount { pub id: Option, @@ -46,17 +52,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 { @@ -77,6 +92,33 @@ 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 + } } // --- OpenID configuration --- @@ -134,6 +176,9 @@ pub struct TeamsConfig { 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, } impl TeamsConfig { @@ -162,10 +207,57 @@ 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, + ), }) } } +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 { @@ -177,6 +269,7 @@ pub struct TeamsAdapter { openid_refresh_lock: Mutex<()>, jwks_cache: RwLock>, jwks_refresh_lock: Mutex<()>, + ingress: Mutex, allow_non_public_endpoints: bool, } @@ -200,6 +293,11 @@ impl TeamsAdapter { client: reqwest::Client, allow_non_public_endpoints: bool, ) -> Self { + let ingress = TeamsIngressRegistry::new( + Duration::from_secs(config.dedupe_ttl_secs), + Duration::from_secs(config.route_ttl_secs), + config.max_route_entries, + ); Self { config, client, @@ -209,6 +307,7 @@ impl TeamsAdapter { openid_refresh_lock: Mutex::new(()), jwks_cache: RwLock::new(None), jwks_refresh_lock: Mutex::new(()), + ingress: Mutex::new(ingress), allow_non_public_endpoints, } } @@ -223,6 +322,18 @@ impl TeamsAdapter { Self::with_client(config, build_http_client(request_timeout), true) } + pub(crate) async fn cleanup_ingress(&self) -> TeamsIngressCleanupStats { + self.ingress.lock().await.cleanup(Instant::now()) + } + + pub(crate) fn route_ttl(&self) -> Duration { + Duration::from_secs(self.config.route_ttl_secs) + } + + pub(crate) fn max_route_entries(&self) -> usize { + self.config.max_route_entries + } + async fn cached_token(&self) -> Option { let cache = self.token_cache.read().await; cache.as_ref().and_then(|cached| { @@ -956,6 +1067,13 @@ pub async fn webhook( } }; + 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"); @@ -975,41 +1093,79 @@ pub async fn webhook( return StatusCode::FORBIDDEN; } + accept_message_activity(state, activity).await +} + +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 = match activity.text.as_deref() { - Some(t) if !t.trim().is_empty() => t.trim(), + Some(text) if !text.trim().is_empty() => text.trim(), _ => return StatusCode::OK, }; - - let conversation_id = activity - .conversation - .as_ref() - .and_then(|c| c.id.as_deref()) - .unwrap_or(""); - let conversation_type = activity + 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(conversation_id) = 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 + .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(|f| f.name.as_deref()) - .unwrap_or("Unknown"); - let activity_id = activity.id.as_deref().unwrap_or(""); + .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; + }; - // B3: Guard against an absent or unsafe service URL before persisting a - // reply route. JWT validation binds this value to Microsoft; the public- - // cloud policy additionally prevents credential-bearing SSRF. - if service_url.is_empty() { - warn!("teams: activity missing service_url, cannot route replies"); - return StatusCode::OK; - } + // 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) => { @@ -1017,31 +1173,67 @@ pub async fn webhook( return StatusCode::BAD_REQUEST; } }; + let conversation_type = activity + .conversation + .as_ref() + .and_then(|conversation| conversation.conversation_type.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("personal"); + let sender_name = activity + .from + .as_ref() + .and_then(|sender| sender.name.as_deref()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("Unknown"); 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 + id: conversation_id.to_owned(), + channel_type: conversation_type.to_owned(), + thread_id: None, }, SenderInfo { - id: sender_id.to_string(), - name: sender_name.to_string(), - display_name: sender_name.to_string(), + id: sender_id.to_owned(), + name: sender_name.to_owned(), + display_name: sender_name.to_owned(), is_bot: false, }, text, activity_id, - vec![], // Teams @mentions parsing deferred to future PR + vec![], ); - - // Store service_url for reply routing - state.teams_service_urls.lock().await.insert( - conversation_id.to_string(), - (validated_service_url.to_string(), Instant::now()), + 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(), + conversation_id: conversation_id.to_owned(), + conversation_type: conversation_type.to_owned(), + inbound_activity_id: activity_id.to_owned(), + reply_chain_root_id: activity.reply_to_id.clone(), + service_url: validated_service_url.clone(), + team_id: activity + .channel_data + .as_ref() + .and_then(|data| data.team.as_ref()) + .and_then(|team| team.id.clone()) + .filter(|id| !id.trim().is_empty()), + channel_id: activity + .channel_data + .as_ref() + .and_then(|data| data.channel.as_ref()) + .and_then(|channel| channel.id.clone()) + .filter(|id| !id.trim().is_empty()), + created_at: now, + }; let json = match serde_json::to_string(&event) { Ok(json) => json, Err(serialization_error) => { @@ -1049,17 +1241,129 @@ pub async fn webhook( return StatusCode::INTERNAL_SERVER_ERROR; } }; - let tenant_id = activity.resolved_tenant_id().unwrap_or(""); - info!( - conversation = conversation_id, - sender = sender_name, - tenant = tenant_id, - service_host = validated_service_url.host_str().unwrap_or("unknown"), - "teams → gateway" - ); - let _ = state.event_tx.send(json); - StatusCode::OK + // Preserve the existing reply path until PR 3 switches outbound routing to + // `event_id`. Cache before the publication transaction so a fast Core + // response cannot race this compatibility lookup. + let service_cache_token = cache_legacy_service_url( + &state, + conversation_id, + validated_service_url.as_str(), + teams.route_ttl(), + teams.max_route_entries(), + ) + .await; + + // 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 => { + remove_legacy_service_url(&state, conversation_id, service_cache_token).await; + StatusCode::SERVICE_UNAVAILABLE + } + } + } + LocalPublishOutcome::AtCapacity => { + remove_legacy_service_url(&state, conversation_id, service_cache_token).await; + warn!("teams: ingress dedupe cache is saturated by active publications"); + StatusCode::SERVICE_UNAVAILABLE + } + LocalPublishOutcome::NoConsumer => { + remove_legacy_service_url(&state, conversation_id, service_cache_token).await; + warn!("teams: no event consumer accepted the activity; returning retryable failure"); + StatusCode::SERVICE_UNAVAILABLE + } + LocalPublishOutcome::StateCommitFailed => { + remove_legacy_service_url(&state, conversation_id, service_cache_token).await; + error!("teams: failed to commit ingress state before local enqueue"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + +async fn cache_legacy_service_url( + state: &crate::AppState, + conversation_id: &str, + service_url: &str, + route_ttl: Duration, + max_entries: usize, +) -> Instant { + let now = Instant::now(); + let mut urls = state.teams_service_urls.lock().await; + urls.retain(|_, (_, inserted_at)| now.saturating_duration_since(*inserted_at) < route_ttl); + if !urls.contains_key(conversation_id) && urls.len() >= max_entries { + if let Some(oldest_conversation) = urls + .iter() + .min_by_key(|(_, (_, inserted_at))| *inserted_at) + .map(|(conversation, _)| conversation.clone()) + { + urls.remove(&oldest_conversation); + warn!( + max_entries, + "teams compatibility route cache evicted its oldest entry at capacity" + ); + } + } + urls.insert(conversation_id.to_owned(), (service_url.to_owned(), now)); + now +} + +async fn remove_legacy_service_url( + state: &crate::AppState, + conversation_id: &str, + inserted_at: Instant, +) { + let mut urls = state.teams_service_urls.lock().await; + let should_remove = urls + .get(conversation_id) + .is_some_and(|(_, current_inserted_at)| *current_inserted_at == inserted_at); + if should_remove { + urls.remove(conversation_id); + } } // --- Reply handler --- @@ -1129,18 +1433,18 @@ mod tests { // --- Bot Connector URL and error hardening --- #[test] - fn connector_url_encodes_segments_and_preserves_service_path() { + 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, - ) - .unwrap(); + )?; assert_eq!( url.as_str(), "https://smba.trafficmanager.net/teams/v3/conversations/a%2Fb%3Fc/activities/message%20id%2F%25" ); + Ok(()) } #[test] @@ -1232,6 +1536,9 @@ mod tests { 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, } } @@ -1251,6 +1558,18 @@ mod tests { }) } + 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 { schema: "openab.gateway.reply.v1".into(), @@ -1285,6 +1604,42 @@ mod tests { id: Some(id.into()), }), channel_data: None, + reply_to_id: None, + } + } + + 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, + }), + 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()), } } @@ -1314,6 +1669,147 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); } + #[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(()) + } + + #[tokio::test] + async fn post_auth_requires_all_route_and_identity_fields() -> anyhow::Result<()> { + let (state, _event_rx) = make_routable_state(); + + 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(()) + } + + #[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"); + + assert_eq!( + accept_message_activity(state.clone(), activity.clone()).await, + StatusCode::SERVICE_UNAVAILABLE + ); + assert!(state.teams_service_urls.lock().await.is_empty()); + + 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.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(()) + } + + #[tokio::test] + 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!( + 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 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); + } + + event_rx.recv().await?; + assert!(matches!( + event_rx.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + Ok(()) + } + #[test] fn tenant_allowed_when_list_empty() { let adapter = TeamsAdapter::new(make_config(vec![])); @@ -1352,42 +1848,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 --- @@ -1420,16 +1920,17 @@ mod tests { // --- 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", @@ -1438,26 +1939,58 @@ mod tests { "from": {"id": "user1", "name": "Alice", "aadObjectId": "aad-123"}, "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"} + } }"#; - 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")); + 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") + ); + 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] @@ -1469,7 +2002,7 @@ mod tests { // --- transport concurrency and HTTP policy --- #[tokio::test] - async fn concurrent_oauth_callers_share_one_refresh() { + async fn concurrent_oauth_callers_share_one_refresh() -> anyhow::Result<()> { let server = MockServer::start().await; let _token = Mock::given(method("POST")) .and(path("/token")) @@ -1487,8 +2020,9 @@ mod tests { 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.unwrap(), "singleflight-token"); - assert_eq!(second.unwrap(), "singleflight-token"); + assert_eq!(first?, "singleflight-token"); + assert_eq!(second?, "singleflight-token"); + Ok(()) } #[tokio::test] @@ -1512,7 +2046,7 @@ mod tests { } #[tokio::test] - async fn concurrent_jwks_callers_share_metadata_and_key_fetches() { + 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")) @@ -1543,8 +2077,9 @@ mod tests { 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.unwrap().keys.len(), 1); - assert_eq!(second.unwrap().keys.len(), 1); + assert_eq!(first?.keys.len(), 1); + assert_eq!(second?.keys.len(), 1); + Ok(()) } #[tokio::test] @@ -1702,7 +2237,7 @@ mod tests { // --- reply command dispatch --- #[tokio::test] - async fn unsupported_commands_never_fall_through_to_send_activity() { + 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)) @@ -1719,9 +2254,7 @@ mod tests { )])); for command in ["add_reaction", "remove_reaction"] { - let outcome = handle_reply(&make_reply(Some(command)), &adapter, &service_urls) - .await - .unwrap(); + let outcome = handle_reply(&make_reply(Some(command)), &adapter, &service_urls).await?; assert_eq!(outcome, None, "reaction command {command} should be a no-op"); } @@ -1739,10 +2272,11 @@ mod tests { "error should identify unsupported command {command}" ); } + Ok(()) } #[tokio::test] - async fn commandless_reply_still_sends_one_activity() { + async fn commandless_reply_still_sends_one_activity() -> anyhow::Result<()> { let server = MockServer::start().await; let _token = Mock::given(method("POST")) .and(path("/token")) @@ -1770,14 +2304,39 @@ mod tests { (server.uri(), std::time::Instant::now()), )])); - let message_id = handle_reply(&make_reply(None), &adapter, &service_urls) - .await - .unwrap(); + let message_id = handle_reply(&make_reply(None), &adapter, &service_urls).await?; assert_eq!(message_id.as_deref(), Some("activity-1")); + 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"), + ]); + 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); + + values.insert("TEAMS_DEDUPE_TTL_SECS", "0"); + values.insert("TEAMS_ROUTE_TTL_SECS", "invalid"); + values.insert("TEAMS_MAX_ROUTE_ENTRIES", "0"); + 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); + 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..ab39910d4 --- /dev/null +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -0,0 +1,517 @@ +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; + +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 { + 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(), + } + } +} + +/// Gateway-local routing material for one authenticated Teams activity. +/// +/// The service URL is intentionally kept out of the wire schema and logging. +/// PR 3 consumes this route by `event_id`; PR 2 owns validation, bounds, expiry, +/// and duplicate-safe publication. +#[allow(dead_code)] +#[derive(Clone)] +pub(super) struct TeamsIngressRoute { + pub(super) key: TeamsRouteKey, + pub(super) event_id: String, + pub(super) tenant_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) 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, +} + +pub(super) enum PublishReservation { + Owner, + AcceptedDuplicate, + PublishingDuplicate(watch::Receiver), + AtCapacity, +} + +#[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, +} + +/// Process-local, bounded Teams route and dedupe 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. +pub(super) struct TeamsIngressRegistry { + routes_by_event: HashMap, + event_by_key: HashMap, + dedupe: 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(), + 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); + } + } + } + + TeamsIngressCleanupStats { + routes_removed: expired_route_ids.len(), + dedupe_entries_removed: expired_dedupe_keys.len(), + stale_publications_removed, + } + } + + #[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 route( + key: TeamsRouteKey, + event_id: &str, + created_at: Instant, + ) -> anyhow::Result { + Ok(TeamsIngressRoute { + tenant_id: "tenant".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, + 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 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/lib.rs b/crates/openab-gateway/src/lib.rs index 007d85880..b1e796afe 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -591,12 +591,18 @@ 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(); 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()), _ => None, }) .map(adapters::teams::TeamsAdapter::new); @@ -690,6 +696,48 @@ 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, +} + +/// 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; + let now = Instant::now(); + let route_ttl = teams.route_ttl(); + let mut service_urls = state.teams_service_urls.lock().await; + let service_urls_before = service_urls.len(); + service_urls.retain(|_, (_, inserted_at)| { + now.saturating_duration_since(*inserted_at) < route_ttl + }); + let service_urls_removed = service_urls_before - service_urls.len(); + + if stats.routes_removed > 0 + || stats.dedupe_entries_removed > 0 + || stats.stale_publications_removed > 0 + || service_urls_removed > 0 + { + tracing::info!( + routes_removed = stats.routes_removed, + dedupe_entries_removed = stats.dedupe_entries_removed, + stale_publications_removed = stats.stale_publications_removed, + service_urls_removed, + "teams ingress state cleanup" + ); + } + } + }); } /// Parameter object for passing resolved Feishu config across the crate @@ -1009,22 +1057,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, dedupe, and compatibility state. + #[cfg(feature = "teams")] + spawn_teams_ingress_cleanup(state.clone()); let app = app.with_state(state.clone()); @@ -1459,6 +1494,9 @@ 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, }); assert!(s.teams.is_some()); assert_eq!(s.teams_webhook_path, "/hook/teams"); @@ -1480,6 +1518,9 @@ 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, }); assert!(s.teams.is_none()); } @@ -1609,7 +1650,7 @@ mod gateway_protocol_tests { 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!("ws://{addr}/ws"); + let url = format!("{}://{addr}/ws", "ws"); let (mut first, _) = tokio_tungstenite::connect_async(&url).await?; let client_hello = schema::GatewayClientHello { @@ -1657,7 +1698,7 @@ mod gateway_protocol_tests { 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!("ws://{addr}/ws"); + let url = format!("{}://{addr}/ws", "ws"); let (mut socket, _) = tokio_tungstenite::connect_async(&url).await?; wait_for_consumers(&state, 1).await?; diff --git a/crates/openab-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index eeefc8d8c..4cf2fb1ef 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -103,6 +103,9 @@ const COVERED: &[&str] = &[ "TEAMS_OAUTH_ENDPOINT", "TEAMS_OPENID_METADATA", "TEAMS_WEBHOOK_PATH", + "TEAMS_DEDUPE_TTL_SECS", + "TEAMS_ROUTE_TTL_SECS", + "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_ALLOW_ALL_USERS", "TEAMS_ALLOWED_USERS", // lineworks diff --git a/docs/config-reference.md b/docs/config-reference.md index fecdbe186..815586004 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -247,6 +247,9 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `oauth_endpoint` | string | Bot Framework | HTTPS endpoint on `login.microsoftonline.com`; use a tenant-specific path for single-tenant bots. Env: `TEAMS_OAUTH_ENDPOINT`. | | `openid_metadata` | string | Bot Framework | HTTPS metadata endpoint on `login.botframework.com`. Env: `TEAMS_OPENID_METADATA`. | | `webhook_path` | string | `/webhook/teams` | Env: `TEAMS_WEBHOOK_PATH`. | +| `dedupe_ttl_secs` | u64 | `600` | Process-local accepted-activity dedupe window. Must be greater than zero. Env: `TEAMS_DEDUPE_TTL_SECS`. | +| `route_ttl_secs` | u64 | `3600` | Gateway-local authenticated ingress route lifetime. Must be greater than zero. Env: `TEAMS_ROUTE_TTL_SECS`. | +| `max_route_entries` | usize | `10000` | Capacity bound applied independently to the route and dedupe caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | | `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | | `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. | diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 4ed8377a3..c6bb3c25d 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -615,6 +615,9 @@ the OAB `configToml` shown for each mode. | `TEAMS_OPENID_METADATA` | No | `https://login.botframework.com/v1/.well-known/openidconfiguration` | OpenID metadata for JWT validation | | `TEAMS_ALLOWED_TENANTS` | No | (allow all) | Comma-separated tenant IDs | | `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | Webhook endpoint path | +| `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | +| `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | +| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route and dedupe caches | ## Troubleshooting diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 767f5c547..e5ff142de 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -40,6 +40,9 @@ app_id = "${TEAMS_APP_ID}" app_secret = "${TEAMS_APP_SECRET}" allowed_tenants = [""] allowed_users = ["29:1abc..."] +dedupe_ttl_secs = 600 +route_ttl_secs = 3600 +max_route_entries = 10000 ``` ### User Trust (`[teams]` section) @@ -323,6 +326,9 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt | `TEAMS_OPENID_METADATA` | No | `https://login.botframework.com/v1/.well-known/openidconfiguration` | HTTPS metadata endpoint on `login.botframework.com` | | `TEAMS_ALLOWED_TENANTS` | No | (allow all) | Comma-separated tenant IDs | | `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | URL path the gateway listens on | +| `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | +| `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | +| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route and dedupe caches | > ⚠️ **M0 supports Microsoft commercial public cloud only.** Sovereign-cloud endpoints and custom OAuth/OpenID proxy hosts are rejected. Bot Connector replies accept only validated HTTPS service URLs on `smba.trafficmanager.net`; redirects cannot cross origin. diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 55a0f6fa0..86a62caac 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -254,10 +254,11 @@ pr = "" [[openab_features]] feature = "group_routing" status = "implemented" -note = "Session keyed by `conversation.id` (+ `conversation_type`); `serviceUrl` cached per conversation for reply routing, refreshed (timestamp) on each reply, with a periodic TTL cleanup task in the gateway." +note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress now records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; the conversation-only `serviceUrl` cache remains temporarily for the PR 1 outbound path and shares the configured route TTL/capacity." source = [ - "crates/openab-gateway/src/adapters/teams.rs#handle_reply", - "crates/openab-gateway/src/lib.rs", + "crates/openab-gateway/src/adapters/teams.rs#accept_message_activity", + "crates/openab-gateway/src/adapters/teams_ingress.rs", + "crates/openab-gateway/src/lib.rs#spawn_teams_ingress_cleanup", ] pr = "" @@ -272,11 +273,11 @@ pr = "" # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ [[quirks]] -date = "2026-07-04" -title = "serviceUrl is per-conversation and must be cached/refreshed" -note = "Teams replies are POSTed to a `serviceUrl` that arrives on each inbound activity and can change over time. The adapter caches `conversation.id → (serviceUrl, timestamp)` on ingress and refreshes the timestamp on every reply to avoid TTL expiry mid-conversation; a background task in the gateway evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies)." +date = "2026-08-07" +title = "serviceUrl remains gateway-local in bounded ephemeral route state" +note = "For message activities, Teams requires non-empty Bot Framework channel, tenant, conversation, activity, sender, and service URL fields. The validated service URL is stored only in a process-local route keyed by app/tenant/conversation/activity and indexed by `event_id`; default route TTL is 3600 s with 10000 entries. The conversation-only compatibility cache remains until PR 3, with the same TTL/capacity. Missing required fields return 400." kind = "openab_decision" -source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" +source = "docs/adr/teams-ephemeral-ingress-state.md" [[quirks]] date = "2026-07-04" @@ -299,6 +300,13 @@ note = "JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) kind = "intrinsic" source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0" +[[quirks]] +date = "2026-08-07" +title = "Accepted activity retries are process-locally deduplicated" +note = "The composite key `(app_id, tenant_id, conversation_id, activity_id)` follows Vacant → Publishing → Accepted. Concurrent duplicates share the owner's local broadcast result; accepted duplicates return 200 without republishing. No consumer or local publish failure returns 503 and removes Publishing state, so a later retry is not blocked. Default dedupe TTL is 600 s and state is bounded to 10000 entries. This is not durable or cross-replica idempotency." +kind = "openab_decision" +source = "docs/adr/teams-ephemeral-ingress-state.md" + [[quirks]] date = "2026-08-06" title = "Bot Connector transport is pinned to public cloud and fails closed" diff --git a/src/main.rs b/src/main.rs index e82a00c1a..e73dbf292 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1302,6 +1302,9 @@ async fn main() -> anyhow::Result<()> { oauth_endpoint: r.oauth_endpoint, openid_metadata: r.openid_metadata, webhook_path: r.webhook_path, + dedupe_ttl_secs: r.dedupe_ttl_secs, + route_ttl_secs: r.route_ttl_secs, + max_route_entries: r.max_route_entries, }); } // First-class `[feishu]` config overrides env-derived values @@ -1315,6 +1318,8 @@ async fn main() -> anyhow::Result<()> { }); } let gw_state = Arc::new(gw_state_inner); + #[cfg(feature = "teams")] + openab_gateway::spawn_teams_ingress_cleanup(gw_state.clone()); // Phase 1 L1 audit (#1356): warn if any active webhook platform has // no transport authentication configured. Called after From aafa098ab5e5ea04cfd88cbd1e1629ce188557c7 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:57:11 +0800 Subject: [PATCH 08/16] feat(teams): acknowledge routed sends with activity IDs --- Cargo.lock | 1 + crates/openab-gateway/Cargo.toml | 1 + crates/openab-gateway/src/adapters/teams.rs | 652 +++++++++++++----- .../src/adapters/teams_ingress.rs | 165 ++++- crates/openab-gateway/src/lib.rs | 372 +++++++++- docs/config-reference.md | 2 + docs/msteams-enterprise.md | 17 +- docs/msteams-selfhosted.md | 18 +- docs/platforms/schema/teams.toml | 31 +- src/unified_adapter.rs | 129 +++- 10 files changed, 1136 insertions(+), 252 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc11ec7e5..010db7164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2607,6 +2607,7 @@ dependencies = [ "chrono", "futures-util", "hmac 0.12.1", + "httpdate", "image", "jsonwebtoken", "parking_lot", diff --git a/crates/openab-gateway/Cargo.toml b/crates/openab-gateway/Cargo.toml index f8de597ba..427546022 100644 --- a/crates/openab-gateway/Cargo.toml +++ b/crates/openab-gateway/Cargo.toml @@ -30,6 +30,7 @@ quick-xml = "0.37" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } parking_lot = "0.12" urlencoding = "2" +httpdate = "1" [dev-dependencies] wiremock = "0.6" diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 1cc9fc529..98b9dcc01 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,5 +1,5 @@ use super::teams_ingress::{ - wait_for_publish, PublishReservation, PublishState, TeamsIngressCleanupStats, + wait_for_publish, PublishReservation, PublishState, RouteLookupError, TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, TeamsRouteKey, DEFAULT_DEDUPE_TTL_SECS, DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, }; @@ -26,6 +26,7 @@ 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, @@ -313,7 +314,7 @@ impl TeamsAdapter { } #[cfg(test)] - fn new_for_test(config: TeamsConfig) -> Self { + pub(crate) fn new_for_test(config: TeamsConfig) -> Self { Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), true) } @@ -322,16 +323,47 @@ impl TeamsAdapter { Self::with_client(config, build_http_client(request_timeout), true) } - pub(crate) async fn cleanup_ingress(&self) -> TeamsIngressCleanupStats { - self.ingress.lock().await.cleanup(Instant::now()) - } - - pub(crate) fn route_ttl(&self) -> Duration { - Duration::from_secs(self.config.route_ttl_secs) + #[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(), + 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, + 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) fn max_route_entries(&self) -> usize { - self.config.max_route_entries + pub(crate) async fn cleanup_ingress(&self) -> TeamsIngressCleanupStats { + self.ingress.lock().await.cleanup(Instant::now()) } async fn cached_token(&self) -> Option { @@ -623,16 +655,39 @@ impl TeamsAdapter { ) } - /// Send a reply via Bot Framework REST API. - pub async fn send_activity( + /// 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 url = self.connector_url(service_url, conversation_id, None)?; - let token = self.get_token().await?; + ) -> 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", @@ -644,26 +699,103 @@ impl TeamsAdapter { body["replyToId"] = serde_json::Value::String(id.to_string()); } - let response = self + let response = match self .client .post(url) .bearer_auth(&token) .json(&body) .send() .await - .map_err(|error| safe_request_error("Bot Framework send", &error))?; - let response = - require_http_success(response, "Bot Framework send", &[token.as_str()]).await?; - let result: serde_json::Value = response - .json() + { + 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(), + }; + } + }; + + 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 retry_after_ms = (status == StatusCode::TOO_MANY_REQUESTS) + .then(|| parse_retry_after_ms(response.headers())) + .flatten(); + let body = read_bounded_error_body(response, &[token.as_str()]).await; + let message = format!("Bot Framework send failed with HTTP {status}: {body}"); + if status.is_server_error() { + WriteOutcome::Unknown { + code: "connector_server_error".into(), + message, + } + } else { + let code = match status.as_u16() { + 401 | 403 => "authorization_rejected", + 413 => "message_too_large", + 429 => "rate_limited", + 300..=399 => "redirect_rejected", + _ => "connector_rejected", + }; + WriteOutcome::Rejected { + code: code.into(), + message, + retry_after_ms, + } + } + } + + /// 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 - .map_err(|_| anyhow::anyhow!("Bot Framework send response was not valid JSON"))?; - result - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_owned) - .ok_or_else(|| anyhow::anyhow!("Bot Framework send response missing activity id")) + { + 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)) + } + } } /// Edit an existing activity (for streaming updates). @@ -821,6 +953,19 @@ fn safe_request_error(operation: &str, error: &reqwest::Error) -> anyhow::Error 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 require_http_success( response: reqwest::Response, operation: &str, @@ -1242,18 +1387,6 @@ async fn accept_message_activity(state: Arc, activity: Activity } }; - // Preserve the existing reply path until PR 3 switches outbound routing to - // `event_id`. Cache before the publication transaction so a fast Core - // response cannot race this compatibility lookup. - let service_cache_token = cache_legacy_service_url( - &state, - conversation_id, - validated_service_url.as_str(), - teams.route_ttl(), - teams.max_route_entries(), - ) - .await; - // 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. @@ -1301,80 +1434,27 @@ async fn accept_message_activity(state: Arc, activity: Activity LocalPublishOutcome::PublishingDuplicate(completion) => { match wait_for_publish(completion).await { PublishState::Accepted => StatusCode::OK, - PublishState::Publishing | PublishState::Failed => { - remove_legacy_service_url(&state, conversation_id, service_cache_token).await; - StatusCode::SERVICE_UNAVAILABLE - } + PublishState::Publishing | PublishState::Failed => StatusCode::SERVICE_UNAVAILABLE, } } LocalPublishOutcome::AtCapacity => { - remove_legacy_service_url(&state, conversation_id, service_cache_token).await; warn!("teams: ingress dedupe cache is saturated by active publications"); StatusCode::SERVICE_UNAVAILABLE } LocalPublishOutcome::NoConsumer => { - remove_legacy_service_url(&state, conversation_id, service_cache_token).await; warn!("teams: no event consumer accepted the activity; returning retryable failure"); StatusCode::SERVICE_UNAVAILABLE } LocalPublishOutcome::StateCommitFailed => { - remove_legacy_service_url(&state, conversation_id, service_cache_token).await; error!("teams: failed to commit ingress state before local enqueue"); StatusCode::INTERNAL_SERVER_ERROR } } } -async fn cache_legacy_service_url( - state: &crate::AppState, - conversation_id: &str, - service_url: &str, - route_ttl: Duration, - max_entries: usize, -) -> Instant { - let now = Instant::now(); - let mut urls = state.teams_service_urls.lock().await; - urls.retain(|_, (_, inserted_at)| now.saturating_duration_since(*inserted_at) < route_ttl); - if !urls.contains_key(conversation_id) && urls.len() >= max_entries { - if let Some(oldest_conversation) = urls - .iter() - .min_by_key(|(_, (_, inserted_at))| *inserted_at) - .map(|(conversation, _)| conversation.clone()) - { - urls.remove(&oldest_conversation); - warn!( - max_entries, - "teams compatibility route cache evicted its oldest entry at capacity" - ); - } - } - urls.insert(conversation_id.to_owned(), (service_url.to_owned(), now)); - now -} - -async fn remove_legacy_service_url( - state: &crate::AppState, - conversation_id: &str, - inserted_at: Instant, -) { - let mut urls = state.teams_service_urls.lock().await; - let should_remove = urls - .get(conversation_id) - .is_some_and(|(_, current_inserted_at)| *current_inserted_at == inserted_at); - if should_remove { - urls.remove(conversation_id); - } -} - // --- Reply handler --- -pub async fn handle_reply( - reply: &GatewayReply, - teams: &TeamsAdapter, - service_urls: &tokio::sync::Mutex< - std::collections::HashMap, - >, -) -> anyhow::Result> { +pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { // Fail closed for commands the Teams adapter does not implement. Falling // through to `send_activity` would turn edit/delete/topic commands into new // messages, producing duplicate or misleading output. Reaction commands @@ -1383,50 +1463,74 @@ pub async fn handle_reply( None => {} Some("add_reaction" | "remove_reaction") => { debug!(command = ?reply.command.as_deref(), "teams: ignoring unsupported reaction command"); - return Ok(None); + return WriteOutcome::Delivered { message_id: None }; + } + Some(command) => { + return WriteOutcome::Rejected { + code: "unsupported_command".into(), + message: format!("unsupported Teams command: {command}"), + retry_after_ms: None, + }; } - Some(command) => anyhow::bail!("unsupported Teams command: {command}"), } - 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 => anyhow::bail!( - "no Teams service_url for conversation {}", - reply.channel.id - ), + let route = { + let mut ingress = teams.ingress.lock().await; + ingress.route_for_reply( + &reply.reply_to, + &reply.channel.id, + reply.quote_message_id.as_deref(), + Instant::now(), + ) + }; + let (route, quote_activity_id) = match route { + Ok(route) => route, + Err(RouteLookupError::NotFound) => { + return WriteOutcome::Rejected { + code: "route_not_found".into(), + message: "Teams ingress route is missing or expired".into(), + retry_after_ms: None, + }; + } + Err(RouteLookupError::ConversationMismatch) => { + return WriteOutcome::Rejected { + code: "route_mismatch".into(), + message: "Teams reply conversation does not match its ingress route".into(), + retry_after_ms: None, + }; } }; - let reply_to_id = if reply.reply_to.is_empty() { - None - } else { - Some(reply.reply_to.as_str()) - }; + 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 = %reply.channel.id, "gateway → teams"); - let id = teams - .send_activity( - &service_url, - &reply.channel.id, + info!(conversation = %route.conversation_id, "gateway → teams"); + let outcome = teams + .send_activity_outcome( + route.service_url.as_str(), + &route.conversation_id, &reply.content.text, - reply_to_id, + quote_activity_id.as_deref(), ) - .await?; - debug!(activity_id = %id, "teams activity sent"); - Ok(Some(id)) + .await; + if let WriteOutcome::Delivered { + message_id: Some(activity_id), + } = &outcome + { + debug!(activity_id, "teams activity sent"); + } + outcome } #[cfg(test)] mod tests { use super::*; use wiremock::{ - matchers::{method, path}, + matchers::{body_json, method, path}, Mock, MockServer, ResponseTemplate, }; @@ -1590,6 +1694,25 @@ mod tests { } } + 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(), @@ -1598,6 +1721,7 @@ mod tests { 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 { @@ -1620,6 +1744,11 @@ mod tests { 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()), @@ -1724,8 +1853,7 @@ mod tests { } #[tokio::test] - async fn no_consumer_returns_503_without_leaving_a_dedupe_tombstone( - ) -> anyhow::Result<()> { + 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 { @@ -1738,8 +1866,6 @@ mod tests { accept_message_activity(state.clone(), activity.clone()).await, StatusCode::SERVICE_UNAVAILABLE ); - assert!(state.teams_service_urls.lock().await.is_empty()); - let mut event_rx = state.event_tx.subscribe(); assert_eq!( accept_message_activity(state.clone(), activity).await, @@ -1913,7 +2039,9 @@ 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()); } @@ -1937,6 +2065,7 @@ mod tests { "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"}, @@ -1964,6 +2093,13 @@ mod tests { 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() @@ -2102,7 +2238,7 @@ mod tests { } #[tokio::test] - async fn connector_success_without_activity_id_is_rejected() { + async fn connector_success_without_activity_id_is_unknown() { let server = MockServer::start().await; let _token = Mock::given(method("POST")) .and(path("/token")) @@ -2121,12 +2257,16 @@ mod tests { .await; let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); - let error = adapter - .send_activity(&server.uri(), "conversation-1", "hello", None) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("missing activity id")); + 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] @@ -2189,17 +2329,19 @@ mod tests { .await; let adapter = TeamsAdapter::new_for_test(make_http_test_config(&server)); - let error = adapter - .send_activity(&server.uri(), "conversation-1", "hello", None) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("500")); - assert!(error.contains("[truncated]")); - assert!(!error.contains("leaked-token")); - assert!(!error.contains("test-token")); - assert!(!error.contains("sensitive.example")); - assert!(error.len() <= TEAMS_ERROR_BODY_LIMIT + 256); + 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] @@ -2225,13 +2367,71 @@ mod tests { Duration::from_millis(75), ); - let error = adapter - .send_activity(&server.uri(), "conversation-1", "hello", None) - .await - .unwrap_err() - .to_string(); - assert!(error.contains("timed out")); - assert!(!error.contains(&server.uri())); + 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 _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 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 --- @@ -2248,14 +2448,14 @@ mod tests { let mut config = make_config(vec![]); config.oauth_endpoint = format!("{}/token", server.uri()); let adapter = TeamsAdapter::new_for_test(config); - let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( - "conversation-1".to_string(), - (server.uri(), std::time::Instant::now()), - )])); for command in ["add_reaction", "remove_reaction"] { - let outcome = handle_reply(&make_reply(Some(command)), &adapter, &service_urls).await?; - assert_eq!(outcome, None, "reaction command {command} should be a no-op"); + 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" + ); } for command in [ @@ -2264,12 +2464,14 @@ mod tests { "delete_message", "future_unknown_command", ] { - let error = handle_reply(&make_reply(Some(command)), &adapter, &service_urls) - .await - .unwrap_err(); + let outcome = handle_reply(&make_reply(Some(command)), &adapter).await; assert!( - error.to_string().contains(command), - "error should identify unsupported command {command}" + matches!( + outcome, + WriteOutcome::Rejected { ref code, ref message, .. } + if code == "unsupported_command" && message.contains(command) + ), + "outcome should identify unsupported command {command}: {outcome:?}" ); } Ok(()) @@ -2289,6 +2491,12 @@ mod tests { .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"})), ) @@ -2299,13 +2507,115 @@ mod tests { let mut config = make_config(vec![]); config.oauth_endpoint = format!("{}/token", server.uri()); let adapter = TeamsAdapter::new_for_test(config); - let service_urls = tokio::sync::Mutex::new(std::collections::HashMap::from([( - "conversation-1".to_string(), - (server.uri(), std::time::Instant::now()), - )])); + accept_test_route(&adapter, &server.uri(), "evt-1", "inbound-1", None).await?; - let message_id = handle_reply(&make_reply(None), &adapter, &service_urls).await?; - assert_eq!(message_id.as_deref(), Some("activity-1")); + 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 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(()) } diff --git a/crates/openab-gateway/src/adapters/teams_ingress.rs b/crates/openab-gateway/src/adapters/teams_ingress.rs index ab39910d4..0d5d31d0f 100644 --- a/crates/openab-gateway/src/adapters/teams_ingress.rs +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -33,14 +33,23 @@ impl TeamsRouteKey { 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(), + } + } } /// Gateway-local routing material for one authenticated Teams activity. /// /// The service URL is intentionally kept out of the wire schema and logging. -/// PR 3 consumes this route by `event_id`; PR 2 owns validation, bounds, expiry, -/// and duplicate-safe publication. -#[allow(dead_code)] +/// 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, @@ -77,6 +86,12 @@ pub(super) enum PublishReservation { AtCapacity, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum RouteLookupError { + NotFound, + ConversationMismatch, +} + #[derive(Debug, Default, Eq, PartialEq)] pub(crate) struct TeamsIngressCleanupStats { pub(crate) routes_removed: usize, @@ -228,6 +243,43 @@ impl TeamsIngressRegistry { } } + 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)) + } + #[cfg(test)] pub(super) fn route_for_event( &mut self, @@ -500,6 +552,113 @@ mod tests { } } + #[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 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_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(); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index b1e796afe..84d601d0d 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -66,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")] @@ -124,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")] @@ -249,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")] @@ -321,6 +318,7 @@ impl AppState { insert( "teams", AdapterCapabilities { + send_ack: true, show_streaming_placeholder: true, message_limit: characters(4096), status_backend: StatusBackend::None, @@ -714,25 +712,14 @@ pub fn spawn_teams_ingress_cleanup(state: Arc) { }; let stats = teams.cleanup_ingress().await; - let now = Instant::now(); - let route_ttl = teams.route_ttl(); - let mut service_urls = state.teams_service_urls.lock().await; - let service_urls_before = service_urls.len(); - service_urls.retain(|_, (_, inserted_at)| { - now.saturating_duration_since(*inserted_at) < route_ttl - }); - let service_urls_removed = service_urls_before - service_urls.len(); - if stats.routes_removed > 0 || stats.dedupe_entries_removed > 0 || stats.stale_publications_removed > 0 - || service_urls_removed > 0 { tracing::info!( routes_removed = stats.routes_removed, dedupe_entries_removed = stats.dedupe_entries_removed, stale_publications_removed = stats.stale_publications_removed, - service_urls_removed, "teams ingress state cleanup" ); } @@ -996,7 +983,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")] @@ -1057,7 +1043,7 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { }); } - // Background: sweep bounded Teams route, dedupe, and compatibility state. + // Background: sweep bounded Teams route and dedupe state. #[cfg(feature = "teams")] spawn_teams_ingress_cleanup(state.clone()); @@ -1156,6 +1142,32 @@ fn build_gateway_hello( } } +#[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}; @@ -1279,23 +1291,39 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: } #[cfg(feature = "teams")] "teams" => { - if let Some(ref teams) = state_for_recv.teams { - if let Err(e) = adapters::teams::handle_reply( - &reply, - teams, - &state_for_recv.teams_service_urls, - ) - .await - { - tracing::error!( - error = %e, + 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, .. } => { + error!( + error_code = %code, + error = %message, command = ?reply.command.as_deref(), "teams reply rejected" ); } - } else { - warn!("reply for teams but adapter not configured"); + schema::WriteOutcome::Unknown { code, message } => { + 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" => { @@ -1504,7 +1532,7 @@ mod l1_audit_tests { let teams = capabilities .get("teams") .expect("configured Teams adapter should advertise capabilities"); - assert!(!teams.send_ack); + assert!(teams.send_ack); assert!(!teams.can_edit); assert_eq!(teams.streaming_mode, super::schema::StreamingMode::Disabled); assert!(teams.show_streaming_placeholder); @@ -1596,6 +1624,11 @@ mod gateway_protocol_tests { 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, @@ -1642,6 +1675,20 @@ mod gateway_protocol_tests { 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, + } + } + #[tokio::test] async fn hello_advertises_requested_capabilities_and_topology() -> anyhow::Result<()> { let (event_tx, _event_rx) = broadcast::channel(8); @@ -1693,6 +1740,273 @@ mod gateway_protocol_tests { Ok(()) } + #[cfg(feature = "teams")] + #[tokio::test] + async fn negotiated_teams_send_returns_real_activity_id_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 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?)?; + assert!( + hello + .capabilities + .get("teams") + .context("Teams capability should be advertised")? + .send_ack + ); + + socket + .send(Message::Text(serde_json::to_string( + &schema::GatewayReply { + 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, + }, + )?)) + .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()) + } + ); + + 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 { + 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, + }, + )?)) + .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, + }); + 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); + } + + #[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 { + 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, + }; + 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); diff --git a/docs/config-reference.md b/docs/config-reference.md index 815586004..cbc6f9848 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -238,6 +238,8 @@ Full first-class Google Chat section (config-first parity, #1379) — credential Full first-class Teams section (config-first parity, #1380) — credentials, connection, and L3 identity trust. Each field resolves: config → `TEAMS_*` env → default. `app_id` + `app_secret` are mandatory (after env fallback); an incomplete section disables the adapter. > ⚠️ **M0 cloud-profile restriction:** Teams transport supports Microsoft commercial public cloud only. The adapter rejects non-HTTPS endpoints, userinfo, non-standard ports, sovereign-cloud hosts, custom proxy hosts, and service URLs outside `smba.trafficmanager.net`. Existing sovereign-cloud or proxy deployments must remain on an earlier release until an explicit cloud profile is available. +> +> Teams outbound replies require the bounded authenticated `event_id` route. A restart, route expiry, or capacity eviction causes a fail-closed `route_not_found`; the user must send a new activity. New Standalone peers advertise required send ACK and return the real Bot Framework activity ID, using `[gateway].gateway_ack_timeout_secs` as the Core wait budget. | Key | Type | Default | Description | |-----|------|---------|-------------| diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index c6bb3c25d..fa9193ed7 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -664,7 +664,22 @@ Check Gateway pod logs: kubectl logs deployment/openab-gateway --tail=50 ``` -Look for: `teams → gateway` (received) → `gateway → teams` (sent) → `teams activity sent` (success) or `teams reply rejected` (failure). +Look for: `teams → gateway` (received) → `gateway → teams` (sent) → +`teams activity sent` (success), `teams reply rejected` (definite failure), or +`teams reply delivery is unknown; not retrying` (the POST may have completed). +New Core↔Gateway peers require a structured send ACK and return the real Teams +activity ID; legacy peers retain fire-and-forget missing-ACK behavior when no +valid hello is negotiated. + +If logs report `route_not_found` or `Teams ingress route is missing or expired`, +the Gateway restarted, the bounded route was evicted, or the route TTL elapsed +before the response. Have the user send a new activity; never bypass the route +with a manually supplied `serviceUrl`. + +> **Release validation:** Before marking Teams PR 3 complete, record personal, +> group-chat, channel-root, channel-reply, and explicit-quote behavior in the +> [D3 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). +> HTTP mocks and a successful Connector activity ID do not close this UX gate. ### JWT validation failed diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index e5ff142de..d18c270a3 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -340,10 +340,11 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt - Single tenant bot → set `TEAMS_OAUTH_ENDPOINT=https://login.microsoftonline.com//oauth2/v2.0/token` - Multi tenant bot → leave default, but verify `TEAMS_APP_ID` and `TEAMS_APP_SECRET` are correct. -**`teams: no service_url for conversation` in gateway logs** +**`route_not_found` or `Teams ingress route is missing or expired`** -- Gateway was restarted and the in-memory cache was cleared. Have the user send another message. -- Or the webhook never arrived — check Bot Framework webhook URL points at the right gateway. +- Gateway was restarted, the bounded route was evicted, or `TEAMS_ROUTE_TTL_SECS` elapsed before the response. Have the user send another message. +- Or the webhook never reached this Gateway process — check the Bot Framework webhook URL and deployment replica count. +- Do not bypass the route by caching or manually injecting a `serviceUrl`; it is authenticated gateway-local state. **`teams JWT validation failed` in gateway logs** @@ -358,9 +359,16 @@ Check `docker compose logs gateway openab` and look for the trace: 2. `processing message channel_platform=teams` (OAB picked up the event) 3. `sending reply to gateway platform=teams` (OAB sent the reply over WS) 4. `gateway → teams` (gateway calling Bot Framework REST API) -5. `teams activity sent` (success) or `teams reply rejected` (failure) +5. `teams activity sent` (success), `teams reply rejected` (definite failure), or `teams reply delivery is unknown; not retrying` (the POST may have completed) -Whichever step is missing tells you where the break is. +Whichever step is missing tells you where the break is. New Core↔Gateway peers +require a structured send ACK and return the real Teams activity ID; legacy +peers keep fire-and-forget missing-ACK behavior after an incomplete hello. + +> **Release validation:** HTTP mocks do not prove Teams reply-chain presentation. +> Before marking PR 3 complete, record the personal, group-chat, channel-root, +> channel-reply, and explicit-quote cases in the +> [D3 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). **Bot doesn't appear when @mentioning in a channel** diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 86a62caac..436d177e2 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -119,10 +119,11 @@ source = "https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-frame [[openab_features]] feature = "send_message" status = "implemented" -note = "Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`." +note = "Core preserves the origin `event_id`; Teams resolves it to a bounded authenticated ingress route, POSTs a `message` activity with `textFormat: markdown`, and returns the real Bot Framework activity ID. New Standalone peers advertise required send ACK; Unified returns the same ID directly." source = [ "crates/openab-core/src/gateway.rs#send_message", - "crates/openab-gateway/src/adapters/teams.rs#send_activity", + "crates/openab-gateway/src/adapters/teams.rs#handle_reply", + "crates/openab-gateway/src/adapters/teams.rs#send_activity_outcome", ] pr = "" @@ -148,8 +149,8 @@ pr = "" [[openab_features]] feature = "reply_quote" -status = "not_implemented" -note = "`GatewayAdapter::send_message_with_reply` puts the target id into `quote_message_id` (the visual-quote field via `send_gateway_reply`), not `reply_to`. The Teams adapter reads only `reply.reply_to` (the triggering-event/origin id) → `replyToId`, and ignores `quote_message_id` entirely — so the intended visual quote never reaches Teams. `replyToId` is a reply-target/context id, not a visual quote." +status = "partial" +note = "`GatewayAdapter::send_message_with_reply` carries an explicit target in `quote_message_id`. Teams maps it to Bot Connector `replyToId` only when the activity is known in the same app/tenant/conversation ingress scope; an unknown target falls back to plain send. Personal/group/channel visual UX remains gated by the D3 Microsoft 365 live-tenant matrix." source = [ "crates/openab-core/src/gateway.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/teams.rs#handle_reply", @@ -254,7 +255,7 @@ pr = "" [[openab_features]] feature = "group_routing" status = "implemented" -note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress now records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; the conversation-only `serviceUrl` cache remains temporarily for the PR 1 outbound path and shares the configured route TTL/capacity." +note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; outbound replies resolve only that event route and verify the reply channel matches its conversation." source = [ "crates/openab-gateway/src/adapters/teams.rs#accept_message_activity", "crates/openab-gateway/src/adapters/teams_ingress.rs", @@ -275,7 +276,7 @@ pr = "" [[quirks]] date = "2026-08-07" title = "serviceUrl remains gateway-local in bounded ephemeral route state" -note = "For message activities, Teams requires non-empty Bot Framework channel, tenant, conversation, activity, sender, and service URL fields. The validated service URL is stored only in a process-local route keyed by app/tenant/conversation/activity and indexed by `event_id`; default route TTL is 3600 s with 10000 entries. The conversation-only compatibility cache remains until PR 3, with the same TTL/capacity. Missing required fields return 400." +note = "For message activities, Teams requires non-empty Bot Framework channel, tenant, conversation, activity, sender, and service URL fields. The validated service URL is stored only in a process-local route keyed by app/tenant/conversation/activity and indexed by `event_id`; default route TTL is 3600 s with 10000 entries. Outbound replies use this route directly; missing required fields return 400." kind = "openab_decision" source = "docs/adr/teams-ephemeral-ingress-state.md" @@ -287,11 +288,11 @@ kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" [[quirks]] -date = "2026-07-04" -title = "The reply/quote target is dropped" -note = "`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id) and maps it to `replyToId`. It never reads `quote_message_id`, so a caller asking for a visual reply/quote gets a plain reply-target `replyToId` at best and no visual quote. This is a distinct gap from the write-side commands." +date = "2026-08-08" +title = "Reply correlation and explicit quote targets have separate identifiers" +note = "`reply_to` remains the OpenAB origin event ID and is used only to find authenticated route state. Normal sends do not copy it into Bot Connector `replyToId`. An explicit `quote_message_id` is emitted as `replyToId` only when known in the same app/tenant/conversation scope; otherwise Teams receives a plain send. The resulting visual UX is still unverified in a live tenant." kind = "openab_decision" -source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" +source = "docs/adr/teams-real-send-acknowledgement.md" [[quirks]] date = "2026-07-04" @@ -317,7 +318,7 @@ source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] date = "2026-08-06" title = "Unsupported write-side commands fail closed" -note = "Teams `handle_reply` sends only commandless replies. Reactions are an explicit no-op; `edit_message`, `delete_message`, `create_topic`, and unknown commands return unsupported before route lookup or network I/O, preventing duplicate or misleading plain messages until native handlers are implemented." +note = "Teams `handle_reply` sends only commandless replies. Reactions are an explicit no-op; `edit_message`, `delete_message`, `create_topic`, and unknown commands return a structured `unsupported_command` rejection before route lookup or network I/O, preventing duplicate or misleading plain messages until native handlers are implemented." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" @@ -385,8 +386,8 @@ kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] -date = "2026-08-06" -title = "Reply correlation and inbound rich content remain incomplete" -note = "Teams `handle_reply` supports hardened plain sends and rejects unsupported commands, but still maps the OpenAB event ID from `reply_to` to Bot Connector `replyToId`, ignores `quote_message_id`, and does not parse inbound attachments or mentions. Real activity correlation remains a separate implementation and live-tenant validation step." +date = "2026-08-08" +title = "Real send acknowledgement is implemented; reply-chain UX remains live-gated" +note = "Teams resolves `reply_to` only as an `event_id` route index, returns the real outbound activity ID, and reports delivered/rejected/unknown outcomes in Standalone and Unified modes. Normal sends omit `replyToId`; explicit route-scoped quotes may set it. Channel root/reply, personal, and group-chat presentation still require the D3 live-tenant matrix, and inbound attachments/mentions remain future work." kind = "openab_decision" -source = "crates/openab-gateway/src/adapters/teams.rs" +source = "docs/adr/teams-real-send-acknowledgement.md" diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index 96a19c3d5..f1ef6a2d7 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -7,6 +7,8 @@ use openab_core::adapter::{ AdapterCapabilities, ChannelRef, ChatAdapter, MessageLimit, MessageRef, StatusBackend, StreamingMode, }; +#[cfg(feature = "teams")] +use openab_gateway::schema::WriteOutcome; use openab_gateway::schema::{Content, GatewayReply, ReplyChannel}; use openab_gateway::AppState; use std::collections::HashMap; @@ -28,7 +30,7 @@ impl UnifiedGatewayAdapter { } /// Dispatch a GatewayReply to the correct platform adapter. - async fn dispatch_reply(&self, reply: &GatewayReply) { + async fn dispatch_reply(&self, reply: &GatewayReply) -> Result> { let client = &self.gw_state.client; match reply.platform.as_str() { #[cfg(feature = "telegram")] @@ -99,21 +101,13 @@ impl UnifiedGatewayAdapter { } #[cfg(feature = "teams")] "teams" => { - if let Some(ref teams) = self.gw_state.teams { - if let Err(e) = openab_gateway::adapters::teams::handle_reply( - reply, - teams, - &self.gw_state.teams_service_urls, - ) - .await - { - tracing::error!( - error = %e, - command = ?reply.command.as_deref(), - "teams reply rejected" - ); - } - } + let teams = self + .gw_state + .teams + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Teams adapter is not configured"))?; + let outcome = openab_gateway::adapters::teams::handle_reply(reply, teams).await; + return Self::teams_outcome_result(outcome, reply.command.is_none()); } #[cfg(feature = "acp")] "acp" => { @@ -122,11 +116,47 @@ impl UnifiedGatewayAdapter { } } other => { - tracing::warn!(platform = other, "unified adapter: unknown platform, cannot route reply"); + tracing::warn!( + platform = other, + "unified adapter: unknown platform, cannot route reply" + ); + } + } + Ok(None) + } + + #[cfg(feature = "teams")] + fn teams_outcome_result( + outcome: WriteOutcome, + require_message_id: bool, + ) -> Result> { + match outcome { + WriteOutcome::Delivered { + message_id: Some(message_id), + } => Ok(Some(message_id)), + WriteOutcome::Delivered { message_id: None } if require_message_id => Err( + anyhow::anyhow!("Teams delivered send without an activity id"), + ), + WriteOutcome::Delivered { message_id: None } => Ok(None), + WriteOutcome::Rejected { code, message, .. } => { + Err(anyhow::anyhow!("Teams rejected write ({code}): {message}")) } + WriteOutcome::Unknown { code, message } => Err(anyhow::anyhow!( + "Teams write outcome unknown ({code}): {message}" + )), } } + fn synthetic_message_id() -> String { + format!( + "unified_{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ) + } + /// Build a GatewayReply from ChatAdapter parameters. fn build_reply( &self, @@ -208,7 +238,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { ), }; AdapterCapabilities { - send_ack: false, + send_ack: cfg!(feature = "teams") && platform == "teams", edit_ack: false, delete_ack: false, can_edit, @@ -228,11 +258,13 @@ impl ChatAdapter for UnifiedGatewayAdapter { async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { let reply = self.build_reply(channel, content, None, None); - self.dispatch_reply(&reply).await; + let message_id = self + .dispatch_reply(&reply) + .await? + .unwrap_or_else(Self::synthetic_message_id); Ok(MessageRef { channel: channel.clone(), - message_id: format!("unified_{:x}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()), + message_id, }) } @@ -243,7 +275,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { title: &str, ) -> Result { let reply = self.build_reply(channel, title, Some("create_topic"), None); - self.dispatch_reply(&reply).await; + self.dispatch_reply(&reply).await?; // Return a thread channel ref with the trigger message as thread_id Ok(ChannelRef { platform: channel.platform.clone(), @@ -258,7 +290,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { let mut reply = self.build_reply(&msg.channel, emoji, Some("add_reaction"), None); // Use the actual platform message_id (not origin_event_id which is a UUID) reply.reply_to = msg.message_id.clone(); - self.dispatch_reply(&reply).await; + self.dispatch_reply(&reply).await?; Ok(()) } @@ -266,7 +298,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { let mut reply = self.build_reply(&msg.channel, emoji, Some("remove_reaction"), None); // Use the actual platform message_id (not origin_event_id which is a UUID) reply.reply_to = msg.message_id.clone(); - self.dispatch_reply(&reply).await; + self.dispatch_reply(&reply).await?; Ok(()) } @@ -274,7 +306,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { let mut reply = self.build_reply(&msg.channel, content, Some("edit_message"), None); // Use the actual platform message_id (e.g. "draft" for streaming, or numeric for edits) reply.reply_to = msg.message_id.clone(); - self.dispatch_reply(&reply).await; + self.dispatch_reply(&reply).await?; Ok(()) } @@ -285,11 +317,13 @@ impl ChatAdapter for UnifiedGatewayAdapter { reply_to_message_id: &str, ) -> Result { let reply = self.build_reply(channel, content, None, Some(reply_to_message_id)); - self.dispatch_reply(&reply).await; + let message_id = self + .dispatch_reply(&reply) + .await? + .unwrap_or_else(Self::synthetic_message_id); Ok(MessageRef { channel: channel.clone(), - message_id: format!("unified_{:x}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()), + message_id, }) } @@ -331,15 +365,54 @@ mod tests { UnifiedGatewayAdapter::new(Arc::new(state)) } + #[cfg(feature = "teams")] #[test] fn teams_capabilities_do_not_inherit_telegram_streaming() { let adapter = adapter_with_telegram_streaming(true); let capabilities = adapter.capabilities("teams"); + assert!(capabilities.send_ack); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); assert!(!capabilities.can_edit); assert_eq!(capabilities.status_backend, StatusBackend::None); } + #[cfg(feature = "teams")] + #[test] + fn teams_outcomes_return_real_activity_id_and_propagate_failure() -> Result<()> { + assert_eq!( + UnifiedGatewayAdapter::teams_outcome_result( + WriteOutcome::Delivered { + message_id: Some("activity-1".into()) + }, + true, + )?, + Some("activity-1".into()) + ); + assert!(UnifiedGatewayAdapter::teams_outcome_result( + WriteOutcome::Delivered { message_id: None }, + true, + ) + .is_err()); + assert!(UnifiedGatewayAdapter::teams_outcome_result( + WriteOutcome::Rejected { + code: "route_not_found".into(), + message: "missing".into(), + retry_after_ms: None, + }, + true, + ) + .is_err()); + assert!(UnifiedGatewayAdapter::teams_outcome_result( + WriteOutcome::Unknown { + code: "request_timeout".into(), + message: "ambiguous".into(), + }, + true, + ) + .is_err()); + Ok(()) + } + #[test] fn telegram_capabilities_follow_telegram_streaming() { let adapter = adapter_with_telegram_streaming(true); From 5c3911638bb2560f9c8553c1e2a97fe6c7ff0367 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:57:42 +0800 Subject: [PATCH 09/16] feat(teams): mutate bot-owned messages --- config.toml.example | 2 +- crates/openab-core/src/adapter.rs | 4 + crates/openab-core/src/gateway.rs | 77 +- .../openab-gateway/src/adapters/acp_server.rs | 1 + crates/openab-gateway/src/adapters/feishu.rs | 8 + .../openab-gateway/src/adapters/googlechat.rs | 8 + crates/openab-gateway/src/adapters/line.rs | 1 + .../openab-gateway/src/adapters/lineworks.rs | 1 + crates/openab-gateway/src/adapters/teams.rs | 823 ++++++++++++++++-- .../src/adapters/teams_ingress.rs | 226 ++++- crates/openab-gateway/src/lib.rs | 91 +- crates/openab-gateway/src/schema.rs | 56 ++ docs/config-reference.md | 4 +- docs/msteams-enterprise.md | 16 +- docs/msteams-selfhosted.md | 13 +- docs/platforms/schema/teams.toml | 33 +- src/unified_adapter.rs | 87 +- 17 files changed, 1310 insertions(+), 141 deletions(-) diff --git a/config.toml.example b/config.toml.example index d500b394b..6bc185762 100644 --- a/config.toml.example +++ b/config.toml.example @@ -136,7 +136,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # webhook_path = "/webhook/teams" # env fallback: TEAMS_WEBHOOK_PATH # dedupe_ttl_secs = 600 # env fallback: TEAMS_DEDUPE_TTL_SECS # route_ttl_secs = 3600 # env fallback: TEAMS_ROUTE_TTL_SECS -# max_route_entries = 10000 # env fallback: TEAMS_MAX_ROUTE_ENTRIES +# max_route_entries = 10000 # independent route/dedupe/ownership caps; env: TEAMS_MAX_ROUTE_ENTRIES # allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS # allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) # # env fallback: TEAMS_ALLOWED_USERS (comma-separated) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 257b4877e..d2bcbfd42 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -372,6 +372,9 @@ 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -386,6 +389,7 @@ impl Default for AdapterCapabilities { send_ack: false, edit_ack: false, delete_ack: false, + supports_target_message_id: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 114916e27..01b4c7df6 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -15,6 +15,23 @@ use tracing::{error, info, warn}; const LEGACY_GATEWAY_REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +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. @@ -31,6 +48,7 @@ fn legacy_gateway_capabilities( send_ack: false, edit_ack: platform == "feishu", delete_ack: false, + supports_target_message_id: false, can_edit, can_delete: platform == "feishu", streaming_mode: if streaming && can_edit { @@ -169,6 +187,11 @@ 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, } #[derive(Serialize)] @@ -429,6 +452,7 @@ impl GatewayAdapter { command: None, request_id: req_id.clone(), quote_message_id: quote_message_id.map(|s| s.to_string()), + target_message_id: None, }; let json = serde_json::to_string(&reply)?; if let Err(e) = self.ws_tx.lock().await.send(Message::Text(json)).await { @@ -531,6 +555,7 @@ async fn send_fire_and_forget( command: None, request_id: None, quote_message_id: None, + target_message_id: None, }; let json = serde_json::to_string(&reply)?; ws_tx.lock().await.send(Message::Text(json)).await?; @@ -716,6 +741,7 @@ impl ChatAdapter for GatewayAdapter { command: Some("create_topic".into()), request_id: Some(req_id.clone()), quote_message_id: None, + target_message_id: None, }; let json = serde_json::to_string(&reply)?; self.ws_tx.lock().await.send(Message::Text(json)).await?; @@ -742,9 +768,12 @@ impl ChatAdapter for GatewayAdapter { } async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + let (negotiated, capabilities) = + self.resolved_capabilities_with_mode(&msg.channel.platform); + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { 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(), @@ -756,6 +785,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("add_reaction".into()), quote_message_id: None, + target_message_id, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -764,9 +794,12 @@ impl ChatAdapter for GatewayAdapter { } async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + let (negotiated, capabilities) = + self.resolved_capabilities_with_mode(&msg.channel.platform); + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { 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(), @@ -778,6 +811,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("remove_reaction".into()), quote_message_id: None, + target_message_id, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -818,9 +852,10 @@ impl ChatAdapter for GatewayAdapter { } else { None }; + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { 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(), @@ -832,6 +867,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("edit_message".into()), quote_message_id: None, + target_message_id, request_id: req_id.clone(), }; let json = serde_json::to_string(&reply)?; @@ -900,9 +936,10 @@ impl ChatAdapter for GatewayAdapter { } else { None }; + let (reply_to, target_message_id) = command_target_fields(msg, negotiated, &capabilities); let reply = GatewayReply { 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(), @@ -914,6 +951,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("delete_message".into()), quote_message_id: None, + target_message_id, request_id: request_id.clone(), }; let json = serde_json::to_string(&reply)?; @@ -2031,6 +2069,37 @@ mod tests { ); } + #[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, + 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 client_hello_wire_shape_is_additive_and_versioned() { let value = serde_json::to_value(build_client_hello()).unwrap(); diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 747132e28..2e1a36913 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -3909,6 +3909,7 @@ mod acp_review_fixes { command: command.map(|c| c.into()), request_id: None, quote_message_id: None, + target_message_id: None, } } diff --git a/crates/openab-gateway/src/adapters/feishu.rs b/crates/openab-gateway/src/adapters/feishu.rs index a379368b3..c76a6acf9 100644 --- a/crates/openab-gateway/src/adapters/feishu.rs +++ b/crates/openab-gateway/src/adapters/feishu.rs @@ -4269,6 +4269,7 @@ mod tests { command: None, request_id: None, quote_message_id: Some("om_specific".into()), + target_message_id: None, }; // quote_message_id should take priority let reply_target = reply.quote_message_id.as_deref() @@ -4295,6 +4296,7 @@ mod tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4320,6 +4322,7 @@ mod tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4386,6 +4389,7 @@ mod tests { command: None, request_id: None, quote_message_id: Some("om_invalid".into()), + target_message_id: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4662,6 +4666,7 @@ mod tests { command: Some("edit_message".into()), request_id: Some("req_seam_1".into()), quote_message_id: None, + target_message_id: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4696,6 +4701,7 @@ mod tests { command: Some("delete_message".into()), request_id: None, quote_message_id: None, + target_message_id: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4755,6 +4761,7 @@ mod tests { command: Some("edit_message".into()), request_id: request_id.map(|s| s.into()), quote_message_id: None, + target_message_id: None, } } @@ -4976,6 +4983,7 @@ mod tests { command: None, request_id: Some("r1".into()), quote_message_id: None, + target_message_id: 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 96bca8b30..7c3508558 100644 --- a/crates/openab-gateway/src/adapters/googlechat.rs +++ b/crates/openab-gateway/src/adapters/googlechat.rs @@ -2069,6 +2069,7 @@ mod tests { command: None, request_id: Some("req_123".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2114,6 +2115,7 @@ mod tests { command: None, request_id: Some("req_fail".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2164,6 +2166,7 @@ mod tests { command: None, request_id: Some("req_empty".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2209,6 +2212,7 @@ mod tests { command: None, request_id: Some("req_multi_fail".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2244,6 +2248,7 @@ mod tests { command: None, request_id: Some("req_notoken".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2290,6 +2295,7 @@ mod tests { command: Some("edit_message".into()), request_id: None, quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2333,6 +2339,7 @@ mod tests { command: None, request_id: Some("req_multi".into()), quote_message_id: None, + target_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2391,6 +2398,7 @@ mod tests { command: None, request_id: Some("req_partial".into()), quote_message_id: None, + target_message_id: 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..eb22b076f 100644 --- a/crates/openab-gateway/src/adapters/line.rs +++ b/crates/openab-gateway/src/adapters/line.rs @@ -1316,6 +1316,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..54f56f220 100644 --- a/crates/openab-gateway/src/adapters/lineworks.rs +++ b/crates/openab-gateway/src/adapters/lineworks.rs @@ -2144,6 +2144,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/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 98b9dcc01..23555d1c5 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,13 +1,14 @@ use super::teams_ingress::{ - wait_for_publish, PublishReservation, PublishState, RouteLookupError, TeamsIngressCleanupStats, - TeamsIngressRegistry, TeamsIngressRoute, TeamsRouteKey, DEFAULT_DEDUPE_TTL_SECS, - DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, + wait_for_publish, OwnershipLookupError, PublishReservation, PublishState, RouteLookupError, + TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, TeamsRouteKey, + DEFAULT_DEDUPE_TTL_SECS, DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, }; use crate::schema::*; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::Deserialize; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock}; @@ -271,6 +272,7 @@ pub struct TeamsAdapter { jwks_cache: RwLock>, jwks_refresh_lock: Mutex<()>, ingress: Mutex, + conversation_writes: Vec>, allow_non_public_endpoints: bool, } @@ -280,6 +282,8 @@ 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_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"; @@ -309,6 +313,7 @@ impl TeamsAdapter { jwks_cache: RwLock::new(None), jwks_refresh_lock: Mutex::new(()), ingress: Mutex::new(ingress), + conversation_writes: (0..TEAMS_WRITE_SHARDS).map(|_| Mutex::new(())).collect(), allow_non_public_endpoints, } } @@ -366,6 +371,22 @@ impl TeamsAdapter { self.ingress.lock().await.cleanup(Instant::now()) } + 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| { @@ -748,30 +769,7 @@ impl TeamsAdapter { }; } - let retry_after_ms = (status == StatusCode::TOO_MANY_REQUESTS) - .then(|| parse_retry_after_ms(response.headers())) - .flatten(); - let body = read_bounded_error_body(response, &[token.as_str()]).await; - let message = format!("Bot Framework send failed with HTTP {status}: {body}"); - if status.is_server_error() { - WriteOutcome::Unknown { - code: "connector_server_error".into(), - message, - } - } else { - let code = match status.as_u16() { - 401 | 403 => "authorization_rejected", - 413 => "message_too_large", - 429 => "rate_limited", - 300..=399 => "redirect_rejected", - _ => "connector_rejected", - }; - WriteOutcome::Rejected { - code: code.into(), - message, - retry_after_ms, - } - } + classify_write_failure(response, "Bot Framework send", &[token.as_str()]).await } /// Compatibility wrapper for callers that predate structured outcomes. @@ -798,33 +796,149 @@ impl TeamsAdapter { } } - /// Edit an existing activity (for streaming updates). - pub async fn update_activity( + async fn mutate_activity_outcome( + &self, + method: reqwest::Method, + service_url: &str, + conversation_id: &str, + activity_id: &str, + body: Option<&serde_json::Value>, + operation: &'static str, + ) -> 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 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); + if let Some(body) = body { + request = 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(operation, &error).to_string(), + }; + } + }; + if response.status().is_success() { + return WriteOutcome::Delivered { message_id: None }; + } + + let outcome = classify_write_failure(response, operation, &[token.as_str()]).await; + if !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 { + retried_rate_limit = true; + tokio::time::sleep(delay).await; + continue; + } + } + } + return outcome; + } + } + + pub async fn update_activity_outcome( &self, service_url: &str, conversation_id: &str, activity_id: &str, text: &str, - ) -> anyhow::Result<()> { - let url = self.connector_url(service_url, conversation_id, Some(activity_id))?; - let token = self.get_token().await?; + ) -> 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), + "Bot Framework update", + ) + .await + } - let response = self - .client - .put(url) - .bearer_auth(&token) - .json(&body) - .send() - .await - .map_err(|error| safe_request_error("Bot Framework update", &error))?; - require_http_success(response, "Bot Framework update", &[token.as_str()]).await?; - Ok(()) + 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, + "Bot Framework delete", + ) + .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, + ) } } @@ -966,6 +1080,47 @@ fn parse_retry_after_ms(headers: &reqwest::header::HeaderMap) -> Option { 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}"); + if status.is_server_error() { + WriteOutcome::Unknown { + code: "connector_server_error".into(), + message, + } + } else { + let code = match status.as_u16() { + 401 | 403 => "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, @@ -1454,51 +1609,46 @@ async fn accept_message_activity(state: Arc, activity: Activity // --- Reply handler --- -pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { - // Fail closed for commands the Teams adapter does not implement. Falling - // through to `send_activity` would turn edit/delete/topic commands into new - // messages, producing duplicate or misleading output. Reaction commands - // remain an intentional no-op until a Teams status backend is implemented. - match reply.command.as_deref() { - None => {} - Some("add_reaction" | "remove_reaction") => { - debug!(command = ?reply.command.as_deref(), "teams: ignoring unsupported reaction command"); - return WriteOutcome::Delivered { message_id: None }; - } - Some(command) => { - return WriteOutcome::Rejected { - code: "unsupported_command".into(), - message: format!("unsupported Teams command: {command}"), - retry_after_ms: None, - }; - } +fn rejected_outcome(code: &str, message: impl Into) -> WriteOutcome { + WriteOutcome::Rejected { + code: code.into(), + message: message.into(), + retry_after_ms: None, } +} - let route = { - let mut ingress = teams.ingress.lock().await; - ingress.route_for_reply( - &reply.reply_to, - &reply.channel.id, - reply.quote_message_id.as_deref(), - Instant::now(), - ) +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 (route, quote_activity_id) = match route { + let _write_guard = teams.lock_conversation(&route).await; + let (route, quote_activity_id) = match resolve_send_route(teams, reply).await { Ok(route) => route, - Err(RouteLookupError::NotFound) => { - return WriteOutcome::Rejected { - code: "route_not_found".into(), - message: "Teams ingress route is missing or expired".into(), - retry_after_ms: None, - }; - } - Err(RouteLookupError::ConversationMismatch) => { - return WriteOutcome::Rejected { - code: "route_mismatch".into(), - message: "Teams reply conversation does not match its ingress route".into(), - retry_after_ms: None, - }; - } + Err(outcome) => return outcome, }; if reply.quote_message_id.is_some() && quote_activity_id.is_none() { @@ -1521,11 +1671,134 @@ pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOu message_id: Some(activity_id), } = &outcome { - debug!(activity_id, "teams activity sent"); + 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 +} + +pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOutcome { + match reply.command.as_deref() { + None => handle_send_reply(reply, teams).await, + Some(command @ ("edit_message" | "delete_message")) => { + handle_owned_mutation(reply, teams, command).await + } + Some("add_reaction" | "remove_reaction") => { + debug!(command = ?reply.command.as_deref(), "teams: ignoring unsupported reaction command"); + WriteOutcome::Delivered { message_id: None } + } + Some(command) => rejected_outcome( + "unsupported_command", + format!("unsupported Teams command: {command}"), + ), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1691,6 +1964,7 @@ mod tests { command: command.map(str::to_owned), request_id: None, quote_message_id: None, + target_message_id: None, } } @@ -2458,12 +2732,7 @@ mod tests { ); } - for command in [ - "create_topic", - "edit_message", - "delete_message", - "future_unknown_command", - ] { + for command in ["create_topic", "future_unknown_command"] { let outcome = handle_reply(&make_reply(Some(command)), &adapter).await; assert!( matches!( @@ -2474,6 +2743,17 @@ mod tests { "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(()) } @@ -2519,6 +2799,369 @@ mod tests { 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<()> { diff --git a/crates/openab-gateway/src/adapters/teams_ingress.rs b/crates/openab-gateway/src/adapters/teams_ingress.rs index 0d5d31d0f..73cfae1eb 100644 --- a/crates/openab-gateway/src/adapters/teams_ingress.rs +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -79,6 +79,11 @@ struct DedupeEntry { completion: watch::Sender, } +struct OwnedActivityEntry { + route: TeamsIngressRoute, + created_at: Instant, +} + pub(super) enum PublishReservation { Owner, AcceptedDuplicate, @@ -92,22 +97,33 @@ pub(super) enum RouteLookupError { ConversationMismatch, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum OwnershipLookupError { + NotOwned, + 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 and dedupe state. +/// 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. +/// 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, @@ -119,6 +135,7 @@ impl TeamsIngressRegistry { 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), @@ -236,10 +253,21 @@ impl TeamsIngressRegistry { } } + 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(), } } @@ -280,6 +308,84 @@ impl TeamsIngressRegistry { Ok((route, quote_activity_id)) } + 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" + ); + } + } + self.owned.insert( + key, + OwnedActivityEntry { + route: route.clone(), + 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 remove_owned(&mut self, route: &TeamsIngressRoute, activity_id: &str) -> bool { + self.owned + .remove(&route.key.with_activity_id(activity_id)) + .is_some() + } + #[cfg(test)] pub(super) fn route_for_event( &mut self, @@ -631,6 +737,122 @@ mod tests { 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 owned_route = route(route_key.clone(), "event-1", base)?; + 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!(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 expired_route_cannot_be_used_for_reply() -> anyhow::Result<()> { let now = Instant::now(); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 84d601d0d..00517f53f 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -319,6 +319,11 @@ impl AppState { "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: characters(4096), status_backend: StatusBackend::None, @@ -715,11 +720,13 @@ pub fn spawn_teams_ingress_cleanup(state: Arc) { 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" ); } @@ -1533,7 +1540,11 @@ mod l1_audit_tests { .get("teams") .expect("configured Teams adapter should advertise capabilities"); assert!(teams.send_ack); - assert!(!teams.can_edit); + assert!(teams.edit_ack); + assert!(teams.delete_ack); + assert!(teams.supports_target_message_id); + 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::None); @@ -1742,7 +1753,8 @@ mod gateway_protocol_tests { #[cfg(feature = "teams")] #[tokio::test] - async fn negotiated_teams_send_returns_real_activity_id_over_websocket() -> anyhow::Result<()> { + 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")) @@ -1762,6 +1774,22 @@ mod gateway_protocol_tests { .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( @@ -1793,13 +1821,14 @@ mod gateway_protocol_tests { )?)) .await?; let hello: schema::GatewayHello = serde_json::from_str(&next_text(&mut socket).await?)?; - assert!( - hello - .capabilities - .get("teams") - .context("Teams capability should be advertised")? - .send_ack - ); + 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( @@ -1819,6 +1848,7 @@ mod gateway_protocol_tests { command: None, request_id: Some("request-1".into()), quote_message_id: None, + target_message_id: None, }, )?)) .await?; @@ -1832,6 +1862,41 @@ mod gateway_protocol_tests { } ); + 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 { + 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()), + }, + )?)) + .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(); @@ -1898,6 +1963,7 @@ mod gateway_protocol_tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, }, )?)) .await?; @@ -1943,8 +2009,9 @@ mod gateway_protocol_tests { .get("teams") .expect("configured Teams adapter must be advertised"); assert!(teams.send_ack); - assert!(!teams.edit_ack); - assert!(!teams.delete_ack); + assert!(teams.edit_ack); + assert!(teams.delete_ack); + assert!(teams.supports_target_message_id); } #[cfg(feature = "teams")] @@ -1967,6 +2034,7 @@ mod gateway_protocol_tests { command: None, request_id: Some("request-1".into()), quote_message_id: None, + target_message_id: None, }; let expected_outcomes = [ schema::WriteOutcome::Delivered { @@ -2032,6 +2100,7 @@ mod gateway_protocol_tests { command: None, request_id: None, quote_message_id: None, + target_message_id: None, }; socket .send(Message::Text(serde_json::to_string(&legacy_reply)?)) diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index c942d8d06..9d8d7a504 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -150,6 +150,9 @@ 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -164,6 +167,7 @@ impl Default for AdapterCapabilities { send_ack: false, edit_ack: false, delete_ack: false, + supports_target_message_id: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, @@ -219,6 +223,11 @@ 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, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -461,12 +470,59 @@ mod protocol_tests { 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, + }, + 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()), + }; + 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_eq!(decoded_without_target.reply_to, "legacy-activity"); + 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.can_edit); assert!(!capabilities.can_delete); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); diff --git a/docs/config-reference.md b/docs/config-reference.md index cbc6f9848..8daa80cd7 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -239,7 +239,7 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > ⚠️ **M0 cloud-profile restriction:** Teams transport supports Microsoft commercial public cloud only. The adapter rejects non-HTTPS endpoints, userinfo, non-standard ports, sovereign-cloud hosts, custom proxy hosts, and service URLs outside `smba.trafficmanager.net`. Existing sovereign-cloud or proxy deployments must remain on an earlier release until an explicit cloud profile is available. > -> Teams outbound replies require the bounded authenticated `event_id` route. A restart, route expiry, or capacity eviction causes a fail-closed `route_not_found`; the user must send a new activity. New Standalone peers advertise required send ACK and return the real Bot Framework activity ID, using `[gateway].gateway_ack_timeout_secs` as the Core wait budget. +> Teams outbound replies require the bounded authenticated `event_id` route. A restart, route expiry, or capacity eviction causes a fail-closed `route_not_found`; the user must send a new activity. New Standalone peers advertise required send ACK and return the real Bot Framework activity ID, using `[gateway].gateway_ack_timeout_secs` as the Core wait budget. Edit/delete are permitted only for IDs in the process-local bot-owned index; restart or ownership expiry makes an older message immutable through OpenAB. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -251,7 +251,7 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `webhook_path` | string | `/webhook/teams` | Env: `TEAMS_WEBHOOK_PATH`. | | `dedupe_ttl_secs` | u64 | `600` | Process-local accepted-activity dedupe window. Must be greater than zero. Env: `TEAMS_DEDUPE_TTL_SECS`. | | `route_ttl_secs` | u64 | `3600` | Gateway-local authenticated ingress route lifetime. Must be greater than zero. Env: `TEAMS_ROUTE_TTL_SECS`. | -| `max_route_entries` | usize | `10000` | Capacity bound applied independently to the route and dedupe caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | +| `max_route_entries` | usize | `10000` | Capacity bound applied independently to route, dedupe, and bot-owned outbound activity caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | | `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | | `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. | diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index fa9193ed7..b9a721a4f 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -617,7 +617,7 @@ the OAB `configToml` shown for each mode. | `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | Webhook endpoint path | | `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | -| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route and dedupe caches | +| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | ## Troubleshooting @@ -676,10 +676,16 @@ the Gateway restarted, the bounded route was evicted, or the route TTL elapsed before the response. Have the user send a new activity; never bypass the route with a manually supplied `serviceUrl`. -> **Release validation:** Before marking Teams PR 3 complete, record personal, -> group-chat, channel-root, channel-reply, and explicit-quote behavior in the -> [D3 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). -> HTTP mocks and a successful Connector activity ID do not close this UX gate. +For `message_not_owned`, `target_origin_not_found`, or `target_scope_*`, verify +that the target is a bot activity created by the same Gateway process and that +its origin event, tenant, and conversation still match. Restart, TTL expiry, +capacity eviction, or a user-message target intentionally fails closed. + +> **Release validation:** Before marking Teams PR 3/PR 4 complete, record +> personal, group-chat, channel-root, channel-reply, explicit-quote, and +> bot-owned update/delete behavior in the +> [Microsoft 365 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). +> HTTP mocks and successful Connector activity IDs do not close this gate. ### JWT validation failed diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index d18c270a3..59396c31d 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -328,7 +328,7 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt | `TEAMS_WEBHOOK_PATH` | No | `/webhook/teams` | URL path the gateway listens on | | `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | -| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route and dedupe caches | +| `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | > ⚠️ **M0 supports Microsoft commercial public cloud only.** Sovereign-cloud endpoints and custom OAuth/OpenID proxy hosts are rejected. Bot Connector replies accept only validated HTTPS service URLs on `smba.trafficmanager.net`; redirects cannot cross origin. @@ -346,6 +346,11 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt - Or the webhook never reached this Gateway process — check the Bot Framework webhook URL and deployment replica count. - Do not bypass the route by caching or manually injecting a `serviceUrl`; it is authenticated gateway-local state. +**`message_not_owned`, `target_origin_not_found`, or `target_scope_*` during edit/delete** + +- OpenAB only mutates activity IDs that this Gateway process confirmed creating. Restart, TTL expiry, capacity eviction, or an externally supplied user-message ID fails closed. +- Have the user trigger a new turn and mutate the newly returned bot activity. Do not copy a user activity ID or a target from another tenant/conversation. + **`teams JWT validation failed` in gateway logs** - The gateway auto-refreshes JWKS on miss, so this usually resolves on retry. @@ -366,9 +371,9 @@ require a structured send ACK and return the real Teams activity ID; legacy peers keep fire-and-forget missing-ACK behavior after an incomplete hello. > **Release validation:** HTTP mocks do not prove Teams reply-chain presentation. -> Before marking PR 3 complete, record the personal, group-chat, channel-root, -> channel-reply, and explicit-quote cases in the -> [D3 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). +> Before marking PR 3/PR 4 complete, record personal, group-chat, channel-root, +> channel-reply, explicit-quote, and bot-owned update/delete cases in the +> [Microsoft 365 live-tenant matrix](msteams-discord-parity-requirements.md#202-live-microsoft-365-tenant-matrix). **Bot doesn't appear when @mentioning in a channel** diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 436d177e2..e5b8b87a0 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -140,10 +140,10 @@ pr = "" [[openab_features]] feature = "streaming" status = "workaround" -note = "Gateway platforms use core's post+edit cosmetic streaming (`use_streaming` just returns the configured `streaming` flag; no native streaming API). The Teams gateway fail-closed rejects `edit_message`, so streaming edits don't reach Teams and cannot create duplicate messages; delivery is effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code." +note = "Teams now supports guarded bot-owned PUT updates, but `streaming_mode` remains `disabled`; ordinary delivery is still send-once. Progressive placeholder/edit lifecycle, failure thresholds, and final fallback remain PR 7 work rather than being enabled implicitly by mutation support." source = [ "crates/openab-core/src/gateway.rs#use_streaming", - "crates/openab-gateway/src/adapters/teams.rs#update_activity", + "crates/openab-gateway/src/adapters/teams.rs#update_activity_outcome", ] pr = "" @@ -159,21 +159,23 @@ pr = "" [[openab_features]] feature = "edit_message" -status = "not_implemented" -note = "Core default/`GatewayAdapter::edit_message` emits an `edit_message` command, but Teams is not in `EDIT_RESPONSE_PLATFORMS` (fire-and-forget) and `handle_reply` fail-closed rejects the unsupported command. No edit occurs, but it also cannot fall through to `send_activity` and post duplicate text." +status = "implemented" +note = "New peers separate origin `reply_to` from `target_message_id`; legacy peers use the overloaded target fallback. Teams accepts PUT only for an activity in the process-local bot-owned index, enforces tenant/conversation scope, serializes same-conversation writes, and returns a required structured edit ACK." source = [ "crates/openab-core/src/gateway.rs#edit_message", - "crates/openab-gateway/src/adapters/teams.rs#handle_reply", + "crates/openab-gateway/src/adapters/teams.rs#handle_owned_mutation", + "crates/openab-gateway/src/adapters/teams.rs#update_activity_outcome", ] pr = "" [[openab_features]] feature = "delete_message" -status = "not_implemented" -note = "The `delete_message` command is fail-closed rejected by `handle_reply`. Platform supports DELETE, but the adapter does not call it yet; importantly, the command cannot fall through to `send_activity`." +status = "implemented" +note = "Teams calls Bot Connector DELETE only for an activity in the process-local bot-owned index and returns a required structured delete ACK. Delivered delete removes ownership; rejected or unknown delete retains it for later reconciliation. Inbound user activities cannot be deleted." source = [ "crates/openab-core/src/gateway.rs#delete_message", - "crates/openab-gateway/src/adapters/teams.rs#handle_reply", + "crates/openab-gateway/src/adapters/teams.rs#handle_owned_mutation", + "crates/openab-gateway/src/adapters/teams.rs#delete_activity_outcome", ] pr = "" @@ -255,7 +257,7 @@ pr = "" [[openab_features]] feature = "group_routing" status = "implemented" -note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; outbound replies resolve only that event route and verify the reply channel matches its conversation." +note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; outbound replies resolve that event route. Confirmed bot sends also enter a bounded app/tenant/conversation/activity ownership index used by edit/delete." source = [ "crates/openab-gateway/src/adapters/teams.rs#accept_message_activity", "crates/openab-gateway/src/adapters/teams_ingress.rs", @@ -276,7 +278,7 @@ pr = "" [[quirks]] date = "2026-08-07" title = "serviceUrl remains gateway-local in bounded ephemeral route state" -note = "For message activities, Teams requires non-empty Bot Framework channel, tenant, conversation, activity, sender, and service URL fields. The validated service URL is stored only in a process-local route keyed by app/tenant/conversation/activity and indexed by `event_id`; default route TTL is 3600 s with 10000 entries. Outbound replies use this route directly; missing required fields return 400." +note = "For message activities, Teams requires non-empty Bot Framework channel, tenant, conversation, activity, sender, and service URL fields. The validated service URL is stored only in process-local route and bot-owned state; default TTL is 3600 s with independent 10000-entry route, dedupe, and ownership bounds. Outbound replies use this state directly; missing required fields return 400." kind = "openab_decision" source = "docs/adr/teams-ephemeral-ingress-state.md" @@ -316,12 +318,19 @@ kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] -date = "2026-08-06" +date = "2026-08-09" title = "Unsupported write-side commands fail closed" -note = "Teams `handle_reply` sends only commandless replies. Reactions are an explicit no-op; `edit_message`, `delete_message`, `create_topic`, and unknown commands return a structured `unsupported_command` rejection before route lookup or network I/O, preventing duplicate or misleading plain messages until native handlers are implemented." +note = "Teams supports commandless sends plus bot-owned edit/delete. Reactions remain an explicit no-op; `create_topic` and unknown commands return a structured `unsupported_command` rejection before route lookup or network I/O, preventing unsupported commands from becoming plain sends." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" +[[quirks]] +date = "2026-08-09" +title = "Edit and delete require process-local bot ownership" +note = "A confirmed send records `(app, tenant, conversation, activity)` ownership. New commands carry an additive `target_message_id` while preserving origin-event `reply_to`; old peers use a unique legacy target fallback. Same-conversation writes use fixed bounded lock shards. PUT/DELETE retry at most once only after explicit `429` with `Retry-After` no greater than one second; timeouts and 5xx remain unknown." +kind = "openab_decision" +source = "docs/adr/teams-owned-message-mutations.md" + [[quirks]] date = "2026-07-04" title = "Bot message budget is ~100 KB UTF-16" diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index f1ef6a2d7..d100f288c 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -181,6 +181,18 @@ impl UnifiedGatewayAdapter { command: command.map(|s| s.into()), request_id: None, quote_message_id: quote_message_id.map(|s| s.into()), + target_message_id: None, + } + } + + fn apply_command_target(&self, reply: &mut GatewayReply, msg: &MessageRef) { + if self + .capabilities(&msg.channel.platform) + .supports_target_message_id + { + reply.target_message_id = Some(msg.message_id.clone()); + } else { + reply.reply_to = msg.message_id.clone(); } } } @@ -227,7 +239,13 @@ impl ChatAdapter for UnifiedGatewayAdapter { StatusBackend::Reactions, ), "wecom" => (false, false, StreamingMode::Disabled, StatusBackend::None), - "teams" | "line" | "lineworks" | "acp" => { + "teams" => ( + cfg!(feature = "teams"), + cfg!(feature = "teams"), + StreamingMode::Disabled, + StatusBackend::None, + ), + "line" | "lineworks" | "acp" => { (false, false, StreamingMode::Disabled, StatusBackend::None) } _ => ( @@ -239,8 +257,9 @@ impl ChatAdapter for UnifiedGatewayAdapter { }; AdapterCapabilities { send_ack: cfg!(feature = "teams") && platform == "teams", - edit_ack: false, - delete_ack: false, + edit_ack: cfg!(feature = "teams") && platform == "teams", + delete_ack: cfg!(feature = "teams") && platform == "teams", + supports_target_message_id: cfg!(feature = "teams") && platform == "teams", can_edit, can_delete, streaming_mode, @@ -288,24 +307,28 @@ impl ChatAdapter for UnifiedGatewayAdapter { async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let mut reply = self.build_reply(&msg.channel, emoji, Some("add_reaction"), None); - // Use the actual platform message_id (not origin_event_id which is a UUID) - reply.reply_to = msg.message_id.clone(); + self.apply_command_target(&mut reply, msg); self.dispatch_reply(&reply).await?; Ok(()) } async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let mut reply = self.build_reply(&msg.channel, emoji, Some("remove_reaction"), None); - // Use the actual platform message_id (not origin_event_id which is a UUID) - reply.reply_to = msg.message_id.clone(); + self.apply_command_target(&mut reply, msg); self.dispatch_reply(&reply).await?; Ok(()) } async fn edit_message(&self, msg: &MessageRef, content: &str) -> Result<()> { let mut reply = self.build_reply(&msg.channel, content, Some("edit_message"), None); - // Use the actual platform message_id (e.g. "draft" for streaming, or numeric for edits) - reply.reply_to = msg.message_id.clone(); + self.apply_command_target(&mut reply, msg); + self.dispatch_reply(&reply).await?; + Ok(()) + } + + async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + let mut reply = self.build_reply(&msg.channel, "", Some("delete_message"), None); + self.apply_command_target(&mut reply, msg); self.dispatch_reply(&reply).await?; Ok(()) } @@ -371,8 +394,12 @@ mod tests { let adapter = adapter_with_telegram_streaming(true); let capabilities = adapter.capabilities("teams"); assert!(capabilities.send_ack); + assert!(capabilities.edit_ack); + assert!(capabilities.delete_ack); + assert!(capabilities.supports_target_message_id); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); - assert!(!capabilities.can_edit); + assert!(capabilities.can_edit); + assert!(capabilities.can_delete); assert_eq!(capabilities.status_backend, StatusBackend::None); } @@ -393,6 +420,13 @@ mod tests { true, ) .is_err()); + assert_eq!( + UnifiedGatewayAdapter::teams_outcome_result( + WriteOutcome::Delivered { message_id: None }, + false, + )?, + None + ); assert!(UnifiedGatewayAdapter::teams_outcome_result( WriteOutcome::Rejected { code: "route_not_found".into(), @@ -413,6 +447,39 @@ mod tests { Ok(()) } + #[cfg(feature = "teams")] + #[test] + fn teams_command_target_preserves_origin_event() { + let adapter = adapter_with_telegram_streaming(false); + let message = MessageRef { + channel: ChannelRef { + platform: "teams".into(), + channel_id: "conversation-1".into(), + thread_id: None, + parent_id: None, + origin_event_id: Some("event-1".into()), + }, + message_id: "activity-1".into(), + }; + let mut reply = + adapter.build_reply(&message.channel, "updated", Some("edit_message"), None); + adapter.apply_command_target(&mut reply, &message); + assert_eq!(reply.reply_to, "event-1"); + assert_eq!(reply.target_message_id.as_deref(), Some("activity-1")); + + let mut legacy_message = message; + legacy_message.channel.platform = "line".into(); + let mut legacy_reply = adapter.build_reply( + &legacy_message.channel, + "updated", + Some("edit_message"), + None, + ); + adapter.apply_command_target(&mut legacy_reply, &legacy_message); + assert_eq!(legacy_reply.reply_to, "activity-1"); + assert!(legacy_reply.target_message_id.is_none()); + } + #[test] fn telegram_capabilities_follow_telegram_streaming() { let adapter = adapter_with_telegram_streaming(true); From 349881a089afe284fdd55eef3a71f6726b58bf5b Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:58:50 +0800 Subject: [PATCH 10/16] feat(teams): add opt-in Connector reactions --- charts/openab/README.md | 1 + charts/openab/templates/gateway.yaml | 4 + charts/openab/values.yaml | 1 + config.toml.example | 1 + crates/openab-core/src/config.rs | 20 +- crates/openab-core/src/dispatch.rs | 96 +++- crates/openab-core/src/reactions.rs | 190 +++++-- crates/openab-gateway/src/adapters/teams.rs | 483 +++++++++++++++++- .../src/adapters/teams_ingress.rs | 140 +++++ crates/openab-gateway/src/lib.rs | 20 +- .../tests/config_first_conformance.rs | 1 + docs/config-reference.md | 3 + docs/msteams-enterprise.md | 9 + docs/msteams-selfhosted.md | 15 +- docs/platforms/schema/teams.toml | 24 +- src/main.rs | 1 + src/unified_adapter.rs | 36 +- 17 files changed, 954 insertions(+), 91 deletions(-) diff --git a/charts/openab/README.md b/charts/openab/README.md index dfac33a38..194b37699 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -48,6 +48,7 @@ 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` | +| `gateway.teams.reactionsEnabled` | Opt in to Microsoft public-preview Bot Connector reactions. | `false` | | `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | | `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | | `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 2a89dc79a..e7b7fcd48 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -108,6 +108,10 @@ 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 }} {{- end }} {{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }} {{- if $hasFeishu }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index fd37b023c..8aea0f7ad 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -461,6 +461,7 @@ 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 # 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 6bc185762..a4fab8c78 100644 --- a/config.toml.example +++ b/config.toml.example @@ -137,6 +137,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # 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 # allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS # allowed_users = ["29:1abc..."] # Bot Framework activity.from.id values (29:…) # # env fallback: TEAMS_ALLOWED_USERS (comma-separated) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 68328038f..edd75d6ae 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1328,9 +1328,13 @@ pub struct TeamsConfig { /// Ephemeral authenticated route lifetime. Env fallback: /// `TEAMS_ROUTE_TTL_SECS` (default 3600 seconds). pub route_ttl_secs: Option, - /// Shared capacity bound for route and dedupe caches. Env fallback: + /// 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, /// 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, @@ -1351,6 +1355,7 @@ pub struct ResolvedTeams { pub dedupe_ttl_secs: u64, pub route_ttl_secs: u64, pub max_route_entries: usize, + pub reactions_enabled: bool, pub allow_all_users: bool, pub allowed_users: Vec, } @@ -1415,6 +1420,11 @@ impl TeamsConfig { "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")) + }), allow_all_users: self.allow_all_users.unwrap_or_else(|| { std::env::var("TEAMS_ALLOW_ALL_USERS") .ok() @@ -3100,6 +3110,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_DEDUPE_TTL_SECS", "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", ] { std::env::remove_var(k); } @@ -3113,6 +3124,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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); // --- config wins over env --- std::env::set_var("TEAMS_APP_ID", "env-app"); @@ -3120,6 +3132,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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"); let cfg = TeamsConfig { app_id: Some("cfg-app".into()), oauth_endpoint: Some("https://cfg.example/token".into()), @@ -3127,6 +3140,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] dedupe_ttl_secs: Some(42), route_ttl_secs: Some(84), max_route_entries: Some(123), + reactions_enabled: Some(true), ..Default::default() }; let r = cfg.resolve(); @@ -3136,8 +3150,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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); // --- empty-string ${} expansion falls through to env --- + std::env::set_var("TEAMS_REACTIONS_ENABLED", "true"); let cfg = TeamsConfig { app_id: Some("".into()), ..Default::default() @@ -3148,6 +3164,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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); // --- trust_config() view --- let cfg = TeamsConfig { @@ -3168,6 +3185,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_DEDUPE_TTL_SECS", "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", ] { std::env::remove_var(k); } diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs index d0315a400..bb9ad8a92 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -625,16 +625,23 @@ async fn dispatch_batch( let batch_size = batch.len(); let session_key = Dispatcher::session_key(thread_channel); - // Apply 👀 only when the platform selected the reactions status backend. - // Assistant/typing/none backends must not leak into the emoji lifecycle. + 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 reaction_status = adapter .capabilities(&thread_channel.platform) .status_backend == StatusBackend::Reactions; if reaction_status { - let queued_emoji = &target.reactions_config().emojis.queued; - for msg in batch.iter() { - let _ = adapter.add_reaction(&msg.trigger_msg, queued_emoji).await; + for msg in &batch { + let _ = adapter + .add_reaction(&msg.trigger_msg, &reactions_config.emojis.queued) + .await; } } @@ -650,8 +657,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 @@ -759,7 +764,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(), @@ -767,7 +771,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( @@ -1447,10 +1452,24 @@ mod tests { } } - /// 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. + #[derive(Default)] + struct MockChatAdapter { + reaction_events: Mutex>, + } + + impl MockChatAdapter { + 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 { @@ -1477,10 +1496,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 { @@ -1525,7 +1551,7 @@ 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(); @@ -1547,6 +1573,38 @@ 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 consumer_dispatches_single_message_as_one_batch() { let calls = run_consumer_with_messages(vec![make_msg("hi", 10)], 10, 24_000).await; @@ -1602,7 +1660,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); @@ -1658,7 +1716,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(), @@ -1696,7 +1754,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 = { diff --git a/crates/openab-core/src/reactions.rs b/crates/openab-core/src/reactions.rs index 6e68f90b6..18027854c 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,142 @@ 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, + 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-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 23555d1c5..1c88f387f 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,13 +1,14 @@ use super::teams_ingress::{ - wait_for_publish, OwnershipLookupError, PublishReservation, PublishState, RouteLookupError, - TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, TeamsRouteKey, - DEFAULT_DEDUPE_TTL_SECS, DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, + wait_for_publish, OwnershipLookupError, PublishReservation, PublishState, ReactionLookupError, + RouteLookupError, TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, + TeamsRouteKey, DEFAULT_DEDUPE_TTL_SECS, DEFAULT_MAX_ROUTE_ENTRIES, DEFAULT_ROUTE_TTL_SECS, }; use crate::schema::*; use axum::extract::State; use axum::http::{HeaderMap, StatusCode}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::Deserialize; +use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -181,6 +182,7 @@ pub struct TeamsConfig { pub dedupe_ttl_secs: u64, pub route_ttl_secs: u64, pub max_route_entries: usize, + pub reactions_enabled: bool, } impl TeamsConfig { @@ -224,10 +226,27 @@ impl TeamsConfig { "TEAMS_MAX_ROUTE_ENTRIES", DEFAULT_MAX_ROUTE_ENTRIES, ), + reactions_enabled: parse_opt_in_bool( + read("TEAMS_REACTIONS_ENABLED"), + "TEAMS_REACTIONS_ENABLED", + ), }) } } +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, @@ -288,6 +307,13 @@ 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), +} + impl TeamsAdapter { pub fn new(config: TeamsConfig) -> Self { Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), false) @@ -298,6 +324,9 @@ impl TeamsAdapter { client: reqwest::Client, allow_non_public_endpoints: bool, ) -> 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), @@ -371,6 +400,10 @@ impl TeamsAdapter { self.ingress.lock().await.cleanup(Instant::now()) } + pub fn reactions_enabled(&self) -> bool { + self.config.reactions_enabled + } + fn conversation_write_shard(route: &TeamsIngressRoute) -> usize { let mut hasher = std::collections::hash_map::DefaultHasher::new(); route.tenant_id.hash(&mut hasher); @@ -667,6 +700,22 @@ impl TeamsAdapter { ) } + 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, @@ -796,25 +845,13 @@ impl TeamsAdapter { } } - async fn mutate_activity_outcome( + async fn idempotent_connector_write_outcome( &self, method: reqwest::Method, - service_url: &str, - conversation_id: &str, - activity_id: &str, - body: Option<&serde_json::Value>, + url: reqwest::Url, + body: ConnectorWriteBody<'_>, operation: &'static str, ) -> 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 token = match self.get_token().await { Ok(token) => token, Err(error) => { @@ -832,9 +869,11 @@ impl TeamsAdapter { .client .request(method.clone(), url.clone()) .bearer_auth(&token); - if let Some(body) = body { - request = request.json(body); - } + 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) => { @@ -863,6 +902,11 @@ impl TeamsAdapter { { let delay = Duration::from_millis(*delay_ms); if code == "rate_limited" && delay <= TEAMS_MUTATION_RETRY_MAX_DELAY { + warn!( + operation, + retry_after_ms = *delay_ms, + "teams: retrying rate-limited Connector write once" + ); retried_rate_limit = true; tokio::time::sleep(delay).await; continue; @@ -873,6 +917,30 @@ impl TeamsAdapter { } } + async fn mutate_activity_outcome( + &self, + method: reqwest::Method, + service_url: &str, + conversation_id: &str, + activity_id: &str, + body: Option<&serde_json::Value>, + operation: &'static str, + ) -> 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, operation) + .await + } + pub async fn update_activity_outcome( &self, service_url: &str, @@ -914,6 +982,77 @@ impl TeamsAdapter { .await } + async fn reaction_activity_outcome( + &self, + method: reqwest::Method, + service_url: &str, + conversation_id: &str, + activity_id: &str, + reaction: &str, + operation: &'static str, + ) -> 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, operation) + .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, + "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, + "Bot Framework remove reaction", + ) + .await + } + /// Compatibility wrapper for callers that predate structured outcomes. pub async fn update_activity( &self, @@ -1044,6 +1183,66 @@ fn connector_url( 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"); @@ -1782,15 +1981,103 @@ async fn handle_owned_mutation( 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 { match reply.command.as_deref() { None => handle_send_reply(reply, teams).await, Some(command @ ("edit_message" | "delete_message")) => { handle_owned_mutation(reply, teams, command).await } - Some("add_reaction" | "remove_reaction") => { - debug!(command = ?reply.command.as_deref(), "teams: ignoring unsupported reaction command"); - WriteOutcome::Delivered { message_id: None } + Some(command @ ("add_reaction" | "remove_reaction")) => { + handle_reaction(reply, teams, command).await } Some(command) => rejected_outcome( "unsupported_command", @@ -1803,7 +2090,7 @@ pub async fn handle_reply(reply: &GatewayReply, teams: &TeamsAdapter) -> WriteOu mod tests { use super::*; use wiremock::{ - matchers::{body_json, method, path}, + matchers::{body_json, header, method, path}, Mock, MockServer, ResponseTemplate, }; @@ -1824,6 +2111,42 @@ mod tests { 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( @@ -1916,6 +2239,7 @@ mod tests { dedupe_ttl_secs: DEFAULT_DEDUPE_TTL_SECS, route_ttl_secs: DEFAULT_ROUTE_TTL_SECS, max_route_entries: DEFAULT_MAX_ROUTE_ENTRIES, + reactions_enabled: false, } } @@ -2757,6 +3081,107 @@ mod tests { 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; @@ -3278,15 +3703,23 @@ mod tests { assert_eq!(config.dedupe_ttl_secs, 42); assert_eq!(config.route_ttl_secs, 84); assert_eq!(config.max_route_entries, 123); + assert!(!config.reactions_enabled); + + values.insert("TEAMS_REACTIONS_ENABLED", "true"); + 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); values.insert("TEAMS_DEDUPE_TTL_SECS", "0"); values.insert("TEAMS_ROUTE_TTL_SECS", "invalid"); values.insert("TEAMS_MAX_ROUTE_ENTRIES", "0"); + values.insert("TEAMS_REACTIONS_ENABLED", "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!(!config.reactions_enabled); Ok(()) } diff --git a/crates/openab-gateway/src/adapters/teams_ingress.rs b/crates/openab-gateway/src/adapters/teams_ingress.rs index 73cfae1eb..1753b07c5 100644 --- a/crates/openab-gateway/src/adapters/teams_ingress.rs +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -105,6 +105,14 @@ pub(super) enum OwnershipLookupError { 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, @@ -308,6 +316,78 @@ impl TeamsIngressRegistry { Ok((route, quote_activity_id)) } + 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, @@ -737,6 +817,66 @@ mod tests { 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(); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 00517f53f..45d824986 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -314,7 +314,7 @@ impl AppState { ); } #[cfg(feature = "teams")] - if self.teams.is_some() { + if let Some(teams) = &self.teams { insert( "teams", AdapterCapabilities { @@ -326,7 +326,11 @@ impl AppState { can_delete: true, show_streaming_placeholder: true, message_limit: characters(4096), - status_backend: StatusBackend::None, + status_backend: if teams.reactions_enabled() { + StatusBackend::Reactions + } else { + StatusBackend::None + }, ..AdapterCapabilities::default() }, ); @@ -597,6 +601,7 @@ impl AppState { 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(); self.teams = adapters::teams::TeamsConfig::from_reader(|k| match k { "TEAMS_APP_ID" => cfg.app_id.clone(), "TEAMS_APP_SECRET" => cfg.app_secret.clone(), @@ -606,6 +611,7 @@ impl AppState { "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()), _ => None, }) .map(adapters::teams::TeamsAdapter::new); @@ -702,6 +708,7 @@ pub struct GatewayTeamsConfig { pub dedupe_ttl_secs: u64, pub route_ttl_secs: u64, pub max_route_entries: usize, + pub reactions_enabled: bool, } /// Start the shared Teams state sweeper for Standalone or Unified mode. @@ -1532,6 +1539,7 @@ mod l1_audit_tests { dedupe_ttl_secs: 600, route_ttl_secs: 3600, max_route_entries: 10_000, + reactions_enabled: true, }); assert!(s.teams.is_some()); assert_eq!(s.teams_webhook_path, "/hook/teams"); @@ -1547,7 +1555,10 @@ mod l1_audit_tests { 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::None); + assert_eq!( + teams.status_backend, + super::schema::StatusBackend::Reactions + ); // Missing secret → adapter disabled (same as env-only semantics). s.apply_teams_config(GatewayTeamsConfig { @@ -1560,6 +1571,7 @@ mod l1_audit_tests { dedupe_ttl_secs: 600, route_ttl_secs: 3600, max_route_entries: 10_000, + reactions_enabled: false, }); assert!(s.teams.is_none()); } @@ -1697,6 +1709,7 @@ mod gateway_protocol_tests { dedupe_ttl_secs: 600, route_ttl_secs: 3600, max_route_entries: 10_000, + reactions_enabled: false, } } @@ -1994,6 +2007,7 @@ mod gateway_protocol_tests { dedupe_ttl_secs: 600, route_ttl_secs: 3600, max_route_entries: 10_000, + reactions_enabled: false, }); let hello = build_gateway_hello( &state, diff --git a/crates/openab-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index 4cf2fb1ef..202b40be5 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -106,6 +106,7 @@ const COVERED: &[&str] = &[ "TEAMS_DEDUPE_TTL_SECS", "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", + "TEAMS_REACTIONS_ENABLED", "TEAMS_ALLOW_ALL_USERS", "TEAMS_ALLOWED_USERS", // lineworks diff --git a/docs/config-reference.md b/docs/config-reference.md index 8daa80cd7..b55c3cb31 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -240,6 +240,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > ⚠️ **M0 cloud-profile restriction:** Teams transport supports Microsoft commercial public cloud only. The adapter rejects non-HTTPS endpoints, userinfo, non-standard ports, sovereign-cloud hosts, custom proxy hosts, and service URLs outside `smba.trafficmanager.net`. Existing sovereign-cloud or proxy deployments must remain on an earlier release until an explicit cloud profile is available. > > Teams outbound replies require the bounded authenticated `event_id` route. A restart, route expiry, or capacity eviction causes a fail-closed `route_not_found`; the user must send a new activity. New Standalone peers advertise required send ACK and return the real Bot Framework activity ID, using `[gateway].gateway_ack_timeout_secs` as the Core wait budget. Edit/delete are permitted only for IDs in the process-local bot-owned index; restart or ownership expiry makes an older message immutable through OpenAB. +> +> `reactions_enabled` is an explicit opt-in to Microsoft's public-preview Bot Connector reaction endpoints. It requires no Graph/RSC permission, but the bot must be installed in the target scope. Disabled mode preserves the legacy reaction no-op. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -252,6 +254,7 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `dedupe_ttl_secs` | u64 | `600` | Process-local accepted-activity dedupe window. Must be greater than zero. Env: `TEAMS_DEDUPE_TTL_SECS`. | | `route_ttl_secs` | u64 | `3600` | Gateway-local authenticated ingress route lifetime. Must be greater than zero. Env: `TEAMS_ROUTE_TTL_SECS`. | | `max_route_entries` | usize | `10000` | Capacity bound applied independently to route, dedupe, and bot-owned outbound activity caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | +| `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise the reaction status backend. Env: `TEAMS_REACTIONS_ENABLED`. | | `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | | `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. | diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index b9a721a4f..5284fadd4 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -230,6 +230,7 @@ agents: oauth_endpoint = "${TEAMS_OAUTH_ENDPOINT}" allowed_tenants = [""] allowed_users = ["29:1abc..."] + # reactions_enabled = true # public-preview live-tenant test only [agent] command = "kiro-cli" @@ -618,6 +619,7 @@ the OAB `configToml` shown for each mode. | `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | +| `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions; no Graph/RSC grant required | ## Troubleshooting @@ -681,6 +683,13 @@ that the target is a bot activity created by the same Gateway process and that its origin event, tenant, and conversation still match. Restart, TTL expiry, capacity eviction, or a user-message target intentionally fails closed. +For the reaction preview, set `[teams].reactions_enabled = true` in Unified mode +or `TEAMS_REACTIONS_ENABLED=true` on the Standalone Gateway. Restart both peers, +then verify `gateway → teams reaction` logs and visible status transitions in +personal, group-chat, and channel scopes. No Graph/RSC consent is needed. A +`reaction_target_not_known` rejection indicates expired or cross-scope route +evidence; inbound user reaction events remain deferred. + > **Release validation:** Before marking Teams PR 3/PR 4 complete, record > personal, group-chat, channel-root, channel-reply, explicit-quote, and > bot-owned update/delete behavior in the diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 59396c31d..2598c8ec1 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -43,6 +43,7 @@ allowed_users = ["29:1abc..."] dedupe_ttl_secs = 600 route_ttl_secs = 3600 max_route_entries = 10000 +reactions_enabled = false # opt in only for the public-preview reaction API ``` ### User Trust (`[teams]` section) @@ -195,6 +196,9 @@ TEAMS_APP_SECRET="" # Multi tenant: leave this line out (uses default) TEAMS_OAUTH_ENDPOINT="https://login.microsoftonline.com//oauth2/v2.0/token" +# Optional Microsoft public-preview status reactions +# TEAMS_REACTIONS_ENABLED=true + # Only needed if you use the Cloudflare Tunnel service below. # Skip this line if you expose the gateway via a different reverse proxy. TUNNEL_TOKEN="" @@ -312,7 +316,7 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt ## Current Limitations -- **Reactions** — status reactions (👀 / 🤔 / ⚡ / 🆗) are silently dropped for Teams replies +- **Reactions** — outbound status reactions are disabled by default and must be explicitly enabled; inbound `messageReaction` events are still ignored - **Thread replies** — all messages in a personal chat or channel share one agent session - **Streaming edits** — replies are sent as one final message, not progressively edited @@ -329,9 +333,18 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt | `TEAMS_DEDUPE_TTL_SECS` | No | `600` | Process-local duplicate suppression window | | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | +| `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions | > ⚠️ **M0 supports Microsoft commercial public cloud only.** Sovereign-cloud endpoints and custom OAuth/OpenID proxy hosts are rejected. Bot Connector replies accept only validated HTTPS service URLs on `smba.trafficmanager.net`; redirects cannot cross origin. +### Test public-preview bot reactions + +No Graph or RSC grant is required. Set `[teams].reactions_enabled = true` in Unified mode, or `TEAMS_REACTIONS_ENABLED=true` on the Standalone Gateway, then restart both peers so hello negotiation advertises `status_backend = reactions`. + +Send the bot a normal message in personal chat, group chat, and a channel mention. During the turn, the reaction on that inbound message should move through the configured status lifecycle. OpenAB maps its default emoji to Teams reaction IDs, serializes the writes with other conversation operations, and retries at most once after an explicit short `429 Retry-After`. + +Expected Gateway logs include `gateway → teams reaction`. A missing reaction with `reaction_target_not_known` means the authenticated event route expired or the target crossed tenant/conversation scope. This preview does not yet process reactions that users add to bot messages. + ## Troubleshooting **401 Unauthorized when bot tries to reply** diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index e5b8b87a0..9443b8a6a 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -42,7 +42,7 @@ source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/ bot_can_add = true bot_can_remove = true bot_receives_events = true -note = "Bot receives reaction events via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot add/remove own reactions supported via SDK reaction APIs / connector." +note = "Microsoft public preview: bot receives `messageReaction` activities and can add/remove reactions through Bot Connector PUT/DELETE endpoints using its existing bot identity; no Graph/RSC grant is required." source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" [capability.edit_message] @@ -181,9 +181,13 @@ pr = "" [[openab_features]] feature = "emoji_reactions" -status = "not_implemented" -note = "`handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams." -source = ["crates/openab-gateway/src/adapters/teams.rs#handle_reply"] +status = "partial" +note = "Default remains a successful no-op. With `teams.reactions_enabled = true`, OpenAB maps status emoji to Teams IDs and calls scoped Bot Connector PUT/DELETE reaction endpoints in Standalone and Unified modes. The API is Microsoft public preview; inbound `messageReaction` activities are still ignored." +source = [ + "crates/openab-gateway/src/adapters/teams.rs#handle_reaction", + "crates/openab-gateway/src/adapters/teams.rs#add_reaction_outcome", + "docs/adr/teams-message-reactions-preview.md", +] pr = "" [[openab_features]] @@ -320,7 +324,7 @@ source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] date = "2026-08-09" title = "Unsupported write-side commands fail closed" -note = "Teams supports commandless sends plus bot-owned edit/delete. Reactions remain an explicit no-op; `create_topic` and unknown commands return a structured `unsupported_command` rejection before route lookup or network I/O, preventing unsupported commands from becoming plain sends." +note = "Teams supports commandless sends plus bot-owned edit/delete. Reactions remain a no-op unless the public-preview opt-in is enabled; `create_topic` and unknown commands return a structured `unsupported_command` rejection before route lookup or network I/O, preventing unsupported commands from becoming plain sends." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/teams.rs#handle_reply" @@ -360,11 +364,11 @@ kind = "intrinsic" source = "https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability" [[quirks]] -date = "2026-07-04" -title = "Reactions supported by platform, unused by OpenAB" -note = "Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams." -kind = "intrinsic" -source = "https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions" +date = "2026-08-09" +title = "Reaction transport is opt-in public preview" +note = "Bots can add/remove reactions through Bot Connector without Graph/RSC and receive `messageReaction` events. OpenAB implements outbound add/remove only when explicitly enabled, with authenticated target scope and a bounded 429 retry; inbound reaction events remain deferred. Microsoft documents a two-reactions-per-second limit." +kind = "openab_decision" +source = "docs/adr/teams-message-reactions-preview.md" [[quirks]] date = "2026-07-04" diff --git a/src/main.rs b/src/main.rs index e73dbf292..cd0985dda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1305,6 +1305,7 @@ async fn main() -> anyhow::Result<()> { dedupe_ttl_secs: r.dedupe_ttl_secs, route_ttl_secs: r.route_ttl_secs, max_route_entries: r.max_route_entries, + reactions_enabled: r.reactions_enabled, }); } // First-class `[feishu]` config overrides env-derived values diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index d100f288c..df767ae47 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -212,6 +212,14 @@ impl ChatAdapter for UnifiedGatewayAdapter { .gw_state .telegram_streaming .unwrap_or(self.gw_state.telegram_rich_messages); + #[cfg(feature = "teams")] + let teams_reactions = self + .gw_state + .teams + .as_ref() + .is_some_and(|teams| teams.reactions_enabled()); + #[cfg(not(feature = "teams"))] + let teams_reactions = false; let (can_edit, can_delete, streaming_mode, status_backend) = match platform { "telegram" => ( self.gw_state.telegram_rich_messages, @@ -243,7 +251,11 @@ impl ChatAdapter for UnifiedGatewayAdapter { cfg!(feature = "teams"), cfg!(feature = "teams"), StreamingMode::Disabled, - StatusBackend::None, + if teams_reactions { + StatusBackend::Reactions + } else { + StatusBackend::None + }, ), "line" | "lineworks" | "acp" => { (false, false, StreamingMode::Disabled, StatusBackend::None) @@ -401,6 +413,28 @@ mod tests { assert!(capabilities.can_edit); assert!(capabilities.can_delete); assert_eq!(capabilities.status_backend, StatusBackend::None); + + let (event_tx, _event_rx) = tokio::sync::broadcast::channel(4); + let mut state = AppState::test_default(event_tx); + state.apply_teams_config(openab_gateway::GatewayTeamsConfig { + app_id: Some("app".into()), + app_secret: Some("secret".into()), + allowed_tenants: Vec::new(), + 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: true, + }); + let adapter = UnifiedGatewayAdapter::new(Arc::new(state)); + assert_eq!( + adapter.capabilities("teams").status_backend, + StatusBackend::Reactions + ); } #[cfg(feature = "teams")] From 556cc0971cd7e974eadc500d3da55dbcb3f65c2e Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 00:59:50 +0800 Subject: [PATCH 11/16] feat(teams): enforce typed scope and mention routing --- charts/openab/README.md | 12 + charts/openab/values.yaml | 8 +- config.toml.example | 8 +- crates/openab-core/src/adapter.rs | 11 + crates/openab-core/src/config.rs | 111 ++- crates/openab-core/src/gateway.rs | 715 +++++++++++++++++++- crates/openab-gateway/src/adapters/teams.rs | 288 +++++++- crates/openab-gateway/src/schema.rs | 111 +++ docs/config-reference.md | 12 +- docs/msteams-enterprise.md | 43 +- docs/msteams-selfhosted.md | 35 +- docs/platforms/schema/teams.toml | 18 +- src/main.rs | 59 +- 13 files changed, 1359 insertions(+), 72 deletions(-) diff --git a/charts/openab/README.md b/charts/openab/README.md index 194b37699..ca5221afb 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -113,6 +113,18 @@ 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. + ### Discord ID precision warning Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision. diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 8aea0f7ad..632bafde4 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -102,6 +102,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"] @@ -416,7 +422,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 diff --git a/config.toml.example b/config.toml.example index a4fab8c78..fdfe0f248 100644 --- a/config.toml.example +++ b/config.toml.example @@ -138,7 +138,13 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # 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 -# allow_all_users = false # env fallback: TEAMS_ALLOW_ALL_USERS +# 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) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index d2bcbfd42..7c7663113 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -669,6 +669,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 diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index edd75d6ae..f310af62e 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1299,8 +1299,9 @@ impl GoogleChatConfig { } } -/// First-class `[teams]` section — credentials, connection, and L3 identity -/// trust for the MS Teams adapter. Config-first invariant (#1375): each field +/// 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)] @@ -1335,6 +1336,16 @@ pub struct TeamsConfig { /// the reaction status backend. Env fallback: `TEAMS_REACTIONS_ENABLED`. /// Defaults to `false` so existing deployments remain side-effect free. pub reactions_enabled: 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, @@ -1356,6 +1367,11 @@ pub struct ResolvedTeams { pub route_ttl_secs: u64, pub max_route_entries: usize, pub reactions_enabled: bool, + 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, } @@ -1400,6 +1416,29 @@ impl TeamsConfig { }) .unwrap_or(default) }; + let bool_with_default = |cfg: Option, env: &str, default: bool| { + cfg.or_else(|| { + // These booleans admit conversation surfaces. An explicitly + // present but malformed value must resolve false rather than + // use the permissive backward-compatible default. + std::env::var(env) + .ok() + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + }) + .unwrap_or(default) + }; + 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"), @@ -1425,6 +1464,15 @@ impl TeamsConfig { .ok() .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) }), + 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() @@ -3111,6 +3159,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", ] { std::env::remove_var(k); } @@ -3125,6 +3177,19 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert_eq!(r.route_ttl_secs, 3600); assert_eq!(r.max_route_entries, 10_000); assert!(!r.reactions_enabled); + 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"); @@ -3133,6 +3198,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_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()), @@ -3141,6 +3210,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] route_ttl_secs: Some(84), max_route_entries: Some(123), reactions_enabled: Some(true), + 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(); @@ -3151,6 +3224,11 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert_eq!(r.route_ttl_secs, 84); assert_eq!(r.max_route_entries, 123); assert!(r.reactions_enabled); + 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"); @@ -3165,6 +3243,17 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert_eq!(r.route_ttl_secs, 83); assert_eq!(r.max_route_entries, 122); assert!(r.reactions_enabled); + 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); + + // --- malformed allow switch fails closed --- + std::env::set_var("TEAMS_ALLOW_PERSONAL", "not-a-boolean"); + let r = TeamsConfig::default().resolve(); + assert!(!r.allow_personal); + assert!(r.scope_policy_configured); // --- trust_config() view --- let cfg = TeamsConfig { @@ -3186,6 +3275,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", + "TEAMS_ALLOWED_TEAMS", + "TEAMS_ALLOWED_CHANNELS", + "TEAMS_ALLOW_PERSONAL", + "TEAMS_ALLOW_GROUP_CHATS", ] { std::env::remove_var(k); } @@ -3335,6 +3428,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" @@ -3358,6 +3455,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); diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 01b4c7df6..33de295ba 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -95,8 +95,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 { @@ -122,9 +146,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)] @@ -171,6 +228,150 @@ 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() +} + #[derive(Serialize)] struct GatewayReply { schema: String, @@ -1025,6 +1226,7 @@ pub struct GatewayParams { pub telegram_rich_messages: bool, pub gateway_ack_timeout_secs: u64, pub stt: crate::config::SttConfig, + pub teams_scope_policy: TeamsScopePolicy, } pub async fn run_gateway_adapter( @@ -1049,6 +1251,7 @@ pub async fn run_gateway_adapter( let telegram_rich_messages = params.telegram_rich_messages; 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) => { @@ -1197,7 +1400,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 { @@ -1212,6 +1415,8 @@ pub async fn run_gateway_adapter( } } + let prompt = strip_recipient_mention(&event); + info!( platform = %event.platform, sender = %event.sender.name, @@ -1243,7 +1448,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(); @@ -1254,7 +1459,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(); @@ -1414,6 +1618,10 @@ pub async fn run_gateway_adapter( } } + if prompt.is_empty() && extra_blocks.is_empty() { + continue; + } + // 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 @@ -1545,6 +1753,7 @@ pub struct GatewayEventContext { pub trusted_bot_ids: HashSet, pub bot_username: Option, pub stt_config: crate::config::SttConfig, + pub teams_scope_policy: TeamsScopePolicy, #[cfg(feature = "filestore")] pub filestore: Option>, } @@ -1600,14 +1809,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 => { @@ -1682,7 +1916,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 { @@ -1692,6 +1926,8 @@ pub async fn process_gateway_event( } } + let prompt = strip_recipient_mention(&event); + tracing::info!( platform = %event.platform, sender = %event.sender.name, @@ -1722,7 +1958,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(); @@ -1862,8 +2101,11 @@ pub async fn process_gateway_event( } } + if prompt.is_empty() && extra_blocks.is_empty() { + return Ok(false); + } + // 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); @@ -2191,6 +2433,65 @@ mod tests { })).unwrap() } + 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> { EventFilterParams { allow_all_channels: true, @@ -2299,6 +2600,386 @@ 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" }, + "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-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 1c88f387f..6e3861a08 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -34,6 +34,8 @@ pub struct Activity { pub tenant: Option, pub channel_data: Option, pub reply_to_id: Option, + #[serde(default)] + pub entities: Vec, } #[allow(dead_code)] @@ -45,6 +47,15 @@ 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, +} + #[allow(dead_code)] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -122,6 +133,92 @@ impl Activity { } 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 --- @@ -1672,24 +1769,31 @@ async fn accept_message_activity(state: Arc, activity: Activity return StatusCode::BAD_REQUEST; } }; - let conversation_type = activity - .conversation - .as_ref() - .and_then(|conversation| conversation.conversation_type.as_deref()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("personal"); + 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 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 event = GatewayEvent::new( + let mut event = GatewayEvent::new( "teams", ChannelInfo { id: conversation_id.to_owned(), - channel_type: conversation_type.to_owned(), + channel_type: conversation_type.clone(), thread_id: None, }, SenderInfo { @@ -1700,8 +1804,11 @@ async fn accept_message_activity(state: Arc, activity: Activity }, text, activity_id, - vec![], + mentions, ); + event.scope = Some(scope); + event.recipient = recipient; + event.mention_entities = mention_entities; let event_id = event.event_id.clone(); let route_key = TeamsRouteKey::new( teams.config.app_id.clone(), @@ -1715,22 +1822,12 @@ async fn accept_message_activity(state: Arc, activity: Activity event_id: event_id.clone(), tenant_id: tenant_id.to_owned(), conversation_id: conversation_id.to_owned(), - conversation_type: conversation_type.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: activity - .channel_data - .as_ref() - .and_then(|data| data.team.as_ref()) - .and_then(|team| team.id.clone()) - .filter(|id| !id.trim().is_empty()), - channel_id: activity - .channel_data - .as_ref() - .and_then(|data| data.channel.as_ref()) - .and_then(|channel| channel.id.clone()) - .filter(|id| !id.trim().is_empty()), + team_id: route_team_id, + channel_id: route_channel_id, created_at: now, }; let json = match serde_json::to_string(&event) { @@ -2327,6 +2424,7 @@ mod tests { }), channel_data: None, reply_to_id: None, + entities: vec![], } } @@ -2367,6 +2465,7 @@ mod tests { }), }), reply_to_id: Some("root-activity".into()), + entities: vec![], } } @@ -2671,7 +2770,12 @@ mod tests { "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)?; assert_eq!(activity.activity_type, "message"); @@ -2716,6 +2820,144 @@ mod tests { .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(()) } diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index 9d8d7a504..00ca7f581 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -15,6 +15,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)] @@ -385,6 +423,9 @@ impl GatewayEvent { }, mentions, message_id: message_id.into(), + scope: None, + recipient: None, + mention_entities: Vec::new(), } } } @@ -516,6 +557,76 @@ mod protocol_tests { 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 missing_capability_fields_default_fail_closed() { let capabilities: AdapterCapabilities = serde_json::from_str("{}").unwrap(); diff --git a/docs/config-reference.md b/docs/config-reference.md index b55c3cb31..281d0e99f 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -235,13 +235,17 @@ Full first-class Google Chat section (config-first parity, #1379) — credential ## `[teams]` -Full first-class Teams section (config-first parity, #1380) — credentials, connection, and L3 identity trust. Each field resolves: config → `TEAMS_*` env → default. `app_id` + `app_secret` are mandatory (after env fallback); an incomplete section disables the adapter. +Full first-class Teams section (config-first parity, #1380) — credentials, connection, typed L2 scope, and L3 identity trust. Each field resolves: config → `TEAMS_*` env → default. `app_id` + `app_secret` are mandatory (after env fallback); an incomplete section disables the embedded adapter. > ⚠️ **M0 cloud-profile restriction:** Teams transport supports Microsoft commercial public cloud only. The adapter rejects non-HTTPS endpoints, userinfo, non-standard ports, sovereign-cloud hosts, custom proxy hosts, and service URLs outside `smba.trafficmanager.net`. Existing sovereign-cloud or proxy deployments must remain on an earlier release until an explicit cloud profile is available. > > Teams outbound replies require the bounded authenticated `event_id` route. A restart, route expiry, or capacity eviction causes a fail-closed `route_not_found`; the user must send a new activity. New Standalone peers advertise required send ACK and return the real Bot Framework activity ID, using `[gateway].gateway_ack_timeout_secs` as the Core wait budget. Edit/delete are permitted only for IDs in the process-local bot-owned index; restart or ownership expiry makes an older message immutable through OpenAB. > > `reactions_enabled` is an explicit opt-in to Microsoft's public-preview Bot Connector reaction endpoints. It requires no Graph/RSC permission, but the bot must be installed in the target scope. Disabled mode preserves the legacy reaction no-op. +> +> Teams Personal, group-chat, and channel scope is derived from the authenticated Bot Framework activity. Presence of any of `allowed_teams`, `allowed_channels`, `allow_personal`, or `allow_group_chats` (or its environment variable) opts into typed L2 policy. With neither list populated, all Team channels are admitted; otherwise a Team **or** channel ID match admits the channel. Personal and group chats use their booleans. L3 user trust is still evaluated independently. The two boolean environment variables accept `true`/`false` or `1`/`0`; any other explicitly present value resolves to `false` (fail closed). +> +> If none of the typed fields is present, Core preserves the pre-PR-5 `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS` conversation-ID behavior for rolling upgrades. This fallback is logged. `ChannelInfo.id` remains the outbound conversation ID; typed scope never changes routing or session keys. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -255,7 +259,11 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `route_ttl_secs` | u64 | `3600` | Gateway-local authenticated ingress route lifetime. Must be greater than zero. Env: `TEAMS_ROUTE_TTL_SECS`. | | `max_route_entries` | usize | `10000` | Capacity bound applied independently to route, dedupe, and bot-owned outbound activity caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | | `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise the reaction status backend. Env: `TEAMS_REACTIONS_ENABLED`. | -| `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `TEAMS_ALLOW_ALL_USERS`. | +| `allowed_teams` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Team IDs admitted for channel conversations. If either scope list is non-empty, Team **or** channel match admits. Env: `TEAMS_ALLOWED_TEAMS` (comma-separated). | +| `allowed_channels` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Teams channel IDs admitted for channel conversations. Env: `TEAMS_ALLOWED_CHANNELS` (comma-separated). | +| `allow_personal` | bool \| omit | `true` | Admit Personal conversations under typed policy. Env: `TEAMS_ALLOW_PERSONAL`. | +| `allow_group_chats` | bool \| omit | `true` | Admit group chats under typed policy. Env: `TEAMS_ALLOW_GROUP_CHATS`. | +| `allow_all_users` | bool \| omit | `false` (deny-all) | Independent L3 identity gate. Env: `TEAMS_ALLOW_ALL_USERS`. | | `allowed_users` | string[] | `[]` | `activity.from.id` values (`29:…`). Env: `TEAMS_ALLOWED_USERS`. |
Previous trust-only description diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 5284fadd4..495bcd796 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -230,6 +230,11 @@ agents: oauth_endpoint = "${TEAMS_OAUTH_ENDPOINT}" allowed_tenants = [""] allowed_users = ["29:1abc..."] + # Presence of any field below opts into typed L2 scope policy. + allowed_teams = [""] + allowed_channels = [] # a Team OR channel match admits channel messages + allow_personal = true + allow_group_chats = false # reactions_enabled = true # public-preview live-tenant test only [agent] @@ -261,11 +266,13 @@ not automatically add `secretEnv` keys to `[agent].inherit_env`. Do **not** add any `TEAMS_*` keys there: the ACP agent does not need these adapter credentials, and inheriting them would make the secret accessible to prompts and tools. -### User Trust and Tenant Scope +### User Trust, Tenant, and Conversation Scope -The recommended configuration restricts both the Azure AD tenant and individual -Bot Framework sender IDs. Find each user's `29:…` sender ID in OAB logs and add -it to `allowed_users`. +The recommended configuration restricts the Azure AD tenant, typed conversation surface, and individual Bot Framework sender IDs. `allowed_tenants` is the Gateway L1 tenant boundary; `allowed_teams` / `allowed_channels` and the Personal/group-chat switches are Core L2 policy; `allowed_users` is the independent Core L3 identity gate. Find each user's `29:…` sender ID in OAB logs and add it to `allowed_users`. + +With both typed channel lists empty, all Team channels are in L2 scope. If either list is non-empty, a Team **or** channel ID match admits the conversation. Personal requires no mention; groupChat and channel messages require a structured Bot Framework mention targeting `recipient.id`. Text that merely looks like `@OpenAB` is not authority. + +Omitting all four typed scope fields preserves and logs the legacy conversation-ID allowlist for rolling Core/Gateway upgrades. It does not replace the outbound conversation ID with a Team or channel ID. To allow every user in the configured tenant instead, replace `allowed_users` with the explicit broad-access opt-in below. Keep `allowed_tenants`; otherwise, @@ -504,6 +511,12 @@ agents: [gateway] url = "ws://openab-gateway:8080/ws" platform = "teams" + + [teams] + allowed_teams = [""] + allowed_channels = [] + allow_personal = true + allow_group_chats = false allowed_users = ["29:1abc..."] [agent] @@ -517,12 +530,9 @@ agents: session_ttl_hours = 24 ``` -The chart mounts `configToml` verbatim. The sample uses an explicit -`allowed_users` list for least privilege; find each user's `29:…` sender ID in -OAB logs. Legacy `[gateway]` compatibility defaults to allow-all when both -`allow_all_users` and `allowed_users` are omitted, so do not omit this trust -configuration. To admit every user that passed the Gateway's tenant check, use -the explicit `allow_all_users = true` opt-in instead. +The chart mounts `configToml` verbatim. The sample uses first-class `[teams]` typed L2 policy plus an explicit L3 `allowed_users` list for least privilege; find each user's `29:…` sender ID in OAB logs. To admit every user that passed the Gateway's tenant and Core scope checks, use the explicit `[teams].allow_all_users = true` opt-in instead. + +For a rolling deployment with an older Gateway, absent additive scope falls back to the legacy `[gateway].allowed_channels` conversation-ID policy. Do not put Team IDs into that legacy field: they are not Bot Connector conversation IDs. Upgrade the Gateway before relying exclusively on Team/channel typed allowlists. Install the chart version validated with Gateway 0.5.4: @@ -605,8 +615,7 @@ sovereign cloud until the runtime makes the issuer and scope cloud-aware. In Unified Mode, the `[teams]` section resolves these variables from the OAB process; inject `TEAMS_APP_SECRET` with `secretEnv`. In Standalone Gateway Mode, -set them on the Gateway through `openab-gateway-teams`. User trust remains in -the OAB `configToml` shown for each mode. +set transport variables on the Gateway through `openab-gateway-teams`. Typed scope and user trust remain in the OAB `configToml` (or Core process environment) shown for each mode; setting them only on a Standalone Gateway does not configure Core routing policy. | Variable | Required | Default | Description | |---|---|---|---| @@ -620,6 +629,12 @@ the OAB `configToml` shown for each mode. | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions; no Graph/RSC grant required | +| `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | +| `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | +| `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | +| `TEAMS_ALLOW_GROUP_CHATS` | No | `true` | Core typed L2 group-chat switch | +| `TEAMS_ALLOW_ALL_USERS` | No | `false` | Core L3 broad user opt-in | +| `TEAMS_ALLOWED_USERS` | No | (empty) | Core L3 Bot Framework sender IDs, comma-separated | ## Troubleshooting @@ -655,8 +670,7 @@ kubectl logs deployment/openab-kiro --tail=50 ``` Confirm that `/webhook/teams` routes to Service `openab-teams`, the Service has -one ready endpoint, and the sender matches both `[teams].allowed_tenants` and -`[teams].allowed_users`. +one ready endpoint, and the activity matches `[teams].allowed_tenants`, the typed Team/channel/Personal/group-chat policy, structured mention rules, and `[teams].allowed_users`. ### Standalone Gateway receives webhook but no reply in Teams @@ -713,6 +727,7 @@ kubectl run openab-metadata-check --rm -i --restart=Never \ - **Credentials in Kubernetes Secrets** — never in ConfigMaps or Deployment manifests - **Rotate client secrets** before expiration — set a reminder based on the expiration chosen in Step 1 - **Use a tenant allowlist** in production — configure `[teams].allowed_tenants` in Unified Mode or `TEAMS_ALLOWED_TENANTS` in Standalone Gateway Mode +- **Bound Core scope and identity independently** — configure the typed Team/channel/Personal/group-chat policy and an explicit `[teams].allowed_users` list; a scope match never bypasses L3 sender trust - **Network policies** — start from default-deny and allow cluster DNS plus the minimum outbound destinations. The M0 public-cloud profile permits the `login.microsoftonline.com` token endpoint, `login.botframework.com` metadata diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 2598c8ec1..3ca6a28d1 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -30,7 +30,7 @@ tables = "off" Set `TEAMS_APP_ID` and `TEAMS_APP_SECRET` on the container. No `[gateway]` needed. -### `[teams]` Section (credentials + trust) +### `[teams]` Section (credentials + typed scope + trust) Since #1380 the `[teams]` section carries the full adapter configuration — config-first with `TEAMS_*` env fallback: @@ -44,8 +44,18 @@ dedupe_ttl_secs = 600 route_ttl_secs = 3600 max_route_entries = 10000 reactions_enabled = false # opt in only for the public-preview reaction API + +# Typed L2 scope. Presence of any of these four fields opts in. +allowed_teams = [] # Team IDs +allowed_channels = [] # Teams channel IDs; Team OR channel match +allow_personal = true +allow_group_chats = true ``` +With both scope lists empty, all Team channels remain open. Personal chats do not require a mention; group chats and Team channel roots/replies require a Bot Framework mention entity whose `mentioned.id` equals `recipient.id`. Plain text such as `@OpenAB` or `OpenAB` without that entity does not trigger the bot. Core removes only the receiving bot's entity text and preserves other mentions. + +If all four typed fields are omitted, Core logs and preserves the legacy conversation-ID L2 allowlist from `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS`. This rolling-upgrade fallback does not change the conversation ID used for sessions and replies. + ### User Trust (`[teams]` section) Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[teams]` section: @@ -226,8 +236,8 @@ services: volumes: - ./config.toml:/etc/openab/config.toml - ./data:/home/agent - env_file: - - .env + environment: + RUST_LOG: info depends_on: - gateway @@ -261,6 +271,16 @@ enabled = true [gateway] url = "ws://gateway:8080/ws" platform = "teams" + +# Core-side typed L2 and L3 policy (also required with a Standalone Gateway). +[teams] +allowed_teams = [] +allowed_channels = [] # both empty = all Team channels +allow_personal = true +allow_group_chats = true +allowed_users = ["29:1abc..."] +# Or replace allowed_users with the explicit broad opt-in: +# allow_all_users = true ``` ### Start the stack @@ -313,6 +333,7 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt - **JWT validation** — every webhook is verified against Microsoft's public JWKS - **Markdown rendering** — replies are sent with `textFormat: "markdown"` - **Tenant allowlist** — set `TEAMS_ALLOWED_TENANTS=,` to restrict which tenants can talk to the bot +- **Typed scope policy** — optionally admit Team IDs, channel IDs, Personal chats, and group chats independently in Core ## Current Limitations @@ -322,6 +343,8 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt ## Environment Variables +Transport variables are read by the embedded adapter or Standalone Gateway. Typed scope and L3 trust variables are read by the Core process; in Standalone mode do not set them only on the Gateway container. + | Variable | Required | Default | Description | |---|---|---|---| | `TEAMS_APP_ID` | Yes | — | Azure AD application (client) ID | @@ -334,6 +357,12 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions | +| `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | +| `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | +| `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | +| `TEAMS_ALLOW_GROUP_CHATS` | No | `true` | Core typed L2 group-chat switch | +| `TEAMS_ALLOW_ALL_USERS` | No | `false` | Core L3 broad user opt-in | +| `TEAMS_ALLOWED_USERS` | No | (empty) | Core L3 Bot Framework sender IDs, comma-separated | > ⚠️ **M0 supports Microsoft commercial public cloud only.** Sovereign-cloud endpoints and custom OAuth/OpenID proxy hosts are rejected. Bot Connector replies accept only validated HTTPS service URLs on `smba.trafficmanager.net`; redirects cannot cross origin. diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 9443b8a6a..b6aa9d412 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -203,7 +203,7 @@ pr = "" [[openab_features]] feature = "media_inbound" status = "not_implemented" -note = "Webhook only reads `activity.text`; attachments are neither parsed nor forwarded (`mentions` passed as empty `vec![]`; no attachment extraction). The `ChannelAccount`/`Activity` DTOs don't even model `attachments`." +note = "Webhook reads text plus structured recipient/mention entities, but attachments are neither parsed nor forwarded. The `Activity` DTO still does not model attachment payloads." source = ["crates/openab-gateway/src/adapters/teams.rs#Activity"] pr = "" @@ -217,10 +217,11 @@ pr = "" [[openab_features]] feature = "trust_gate" status = "implemented" -note = "Two layers: platform-level `check_tenant` (optional `allowed_tenants` allowlist) at ingress, plus core's shared `gate_incoming` (L2 scope + L3 identity) applied to all gateway events in `process_gateway_event`." +note = "Three ordered layers: Gateway tenant validation; Core typed Personal/groupChat/Team-or-channel L2 policy using authenticated additive scope; then the shared per-platform L3 sender-identity gate. Omitting all typed settings preserves the logged legacy conversation-ID L2 fallback for rolling upgrades." source = [ "crates/openab-gateway/src/adapters/teams.rs#check_tenant", - "crates/openab-core/src/adapter.rs#gate_incoming", + "crates/openab-core/src/gateway.rs#TeamsScopePolicy", + "crates/openab-core/src/adapter.rs#gate_identity", ] pr = "" @@ -233,11 +234,12 @@ pr = "" [[openab_features]] feature = "mention_gating" -status = "partial" -note = "Core `should_skip_event` enforces @mention gating in groups when `bot_username` is set — but the Teams webhook forwards `mentions: vec![]` ('@mentions parsing deferred to future PR'), so gating can't match a Teams mention. It also only fires for `channel_type` `group`/`supergroup`; Teams sends `groupChat`/`channel`, which don't match. Teams itself only delivers @mentioned messages in channels, which mitigates this at the platform layer." +status = "implemented" +note = "Gateway parses Bot Framework mention entities and publishes entity IDs plus exact text with `recipient.id`. Personal messages need no mention; groupChat and channel roots/replies require a structured entity whose mentioned ID equals the recipient bot. Plain-text lookalikes do not trigger. Core removes only recipient entity occurrences and preserves other mentions/text." source = [ + "crates/openab-gateway/src/adapters/teams.rs#mention_info", "crates/openab-core/src/gateway.rs#should_skip_event", - "crates/openab-gateway/src/adapters/teams.rs#handle_reply", + "crates/openab-core/src/gateway.rs#strip_recipient_mention", ] pr = "" @@ -261,7 +263,7 @@ pr = "" [[openab_features]] feature = "group_routing" status = "implemented" -note = "Session remains keyed by `conversation.id` (+ `conversation_type`). Authenticated ingress records a bounded gateway-local route under composite app/tenant/conversation/activity identity plus `event_id`; outbound replies resolve that event route. Confirmed bot sends also enter a bounded app/tenant/conversation/activity ownership index used by edit/delete." +note = "Authenticated ingress publishes typed Personal/groupChat/channel trust scope, including Team and channel IDs where required. Session and outbound routing deliberately remain keyed by `conversation.id`; bounded routes and bot ownership use composite app/tenant/conversation/activity identity plus `event_id`." source = [ "crates/openab-gateway/src/adapters/teams.rs#accept_message_activity", "crates/openab-gateway/src/adapters/teams_ingress.rs", @@ -401,6 +403,6 @@ source = "crates/openab-gateway/src/adapters/teams.rs" [[quirks]] date = "2026-08-08" title = "Real send acknowledgement is implemented; reply-chain UX remains live-gated" -note = "Teams resolves `reply_to` only as an `event_id` route index, returns the real outbound activity ID, and reports delivered/rejected/unknown outcomes in Standalone and Unified modes. Normal sends omit `replyToId`; explicit route-scoped quotes may set it. Channel root/reply, personal, and group-chat presentation still require the D3 live-tenant matrix, and inbound attachments/mentions remain future work." +note = "Teams resolves `reply_to` only as an `event_id` route index, returns the real outbound activity ID, and reports delivered/rejected/unknown outcomes in Standalone and Unified modes. Normal sends omit `replyToId`; explicit route-scoped quotes may set it. Typed scope and mentions are handled separately; inbound attachments remain future work." kind = "openab_decision" source = "docs/adr/teams-real-send-acknowledgement.md" diff --git a/src/main.rs b/src/main.rs index cd0985dda..f49821966 100644 --- a/src/main.rs +++ b/src/main.rs @@ -254,7 +254,7 @@ fn gateway_section_trust(gw: &config::GatewayConfig) -> openab_core::trust::Trus &gw.allowed_channels, )), gw.allowed_channels.clone(), - None, // allow_dm unused in Phase 1 (is_dm passed as false) + None, // allow_dm unused in the legacy no-scope path Some(config::resolve_allow_all( gw.allow_all_users, &gw.allowed_users, @@ -263,6 +263,39 @@ fn gateway_section_trust(gw: &config::GatewayConfig) -> openab_core::trust::Trus ) } +/// Build the Teams typed-scope L2 policy while retaining the exact legacy +/// conversation-ID fallback when no Teams scope field or env var is present. +fn teams_scope_policy(cfg: &config::Config) -> gateway::TeamsScopePolicy { + let legacy_allowed: Vec = std::env::var("GATEWAY_ALLOWED_CHANNELS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect(); + let legacy_allow_all = std::env::var("GATEWAY_ALLOW_ALL_CHANNELS") + .map(|value| value != "0" && !value.eq_ignore_ascii_case("false")) + .unwrap_or(true); + let (legacy_allow_all, legacy_allowed) = match cfg.gateway.as_ref() { + Some(gateway) if gateway.platform.eq_ignore_ascii_case("teams") => ( + config::resolve_allow_all(gateway.allow_all_channels, &gateway.allowed_channels), + gateway.allowed_channels.clone(), + ), + _ => (legacy_allow_all, legacy_allowed), + }; + + let resolved = cfg.teams.clone().unwrap_or_default().resolve(); + gateway::TeamsScopePolicy::new( + resolved.scope_policy_configured, + resolved.allowed_teams, + resolved.allowed_channels, + resolved.allow_personal, + resolved.allow_group_chats, + legacy_allow_all, + legacy_allowed, + ) +} + /// Apply a platform's first-class trust section to the registry, or — when the /// platform is active but still trust-driven by the deprecated uniform /// `GATEWAY_ALLOW_ALL_USERS`/`GATEWAY_ALLOWED_USERS` env — log the Phase 1 @@ -488,6 +521,28 @@ async fn main() -> anyhow::Result<()> { ))] let unified_platform_enabled = has_unified_platform(&cfg); + let teams_scope_policy = teams_scope_policy(&cfg); + let teams_routing_active = cfg + .gateway + .as_ref() + .is_some_and(|gateway| gateway.platform.eq_ignore_ascii_case("teams")) + || cfg.teams.is_some() + || std::env::var_os("TEAMS_APP_ID").is_some(); + if teams_routing_active && teams_scope_policy.uses_legacy_fallback() { + if teams_scope_policy.legacy_scope_restricted() { + warn!( + "Teams typed scope settings are absent; preserving restricted legacy \ + conversation-ID L2 behavior. Configure [teams].allowed_teams/allowed_channels \ + or allow_personal/allow_group_chats to opt into typed scope policy." + ); + } else { + info!( + "Teams typed scope settings are absent; preserving open legacy \ + conversation-ID L2 behavior" + ); + } + } + let shutdown_hook = cfg.hooks.pre_shutdown.clone(); // Shared MCP-over-ACP tunnel registry (D6-a'): the gateway populates it per session; the @@ -1107,6 +1162,7 @@ async fn main() -> anyhow::Result<()> { telegram_rich_messages: gw_cfg.telegram_rich_messages, gateway_ack_timeout_secs: gw_cfg.gateway_ack_timeout_secs, stt: cfg.stt.clone(), + teams_scope_policy: teams_scope_policy.clone(), }; let gw_router = router.clone(); #[cfg(feature = "filestore")] @@ -1518,6 +1574,7 @@ async fn main() -> anyhow::Result<()> { trusted_bot_ids: gw_trusted_bot_ids, bot_username: gw_bot_username, stt_config: cfg.stt.clone(), + teams_scope_policy: teams_scope_policy.clone(), #[cfg(feature = "filestore")] filestore: filestore.clone(), }); From 5012ab5c5d500330f2f8544582dcc7607b6c76b6 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 01:00:58 +0800 Subject: [PATCH 12/16] feat(teams): add processing message lifecycle --- config.toml.example | 1 + crates/openab-core/src/adapter.rs | 80 ++++-- crates/openab-core/src/config.rs | 63 ++++- crates/openab-core/src/dispatch.rs | 64 ++++- crates/openab-core/src/gateway.rs | 104 +++++++ crates/openab-core/src/lib.rs | 1 + crates/openab-core/src/status.rs | 408 ++++++++++++++++++++++++++++ crates/openab-gateway/src/lib.rs | 24 ++ crates/openab-gateway/src/schema.rs | 28 ++ docs/config-reference.md | 5 +- docs/msteams-enterprise.md | 11 + docs/msteams-selfhosted.md | 10 +- docs/platforms/schema/teams.toml | 7 + src/main.rs | 27 +- src/unified_adapter.rs | 129 ++++++--- 15 files changed, 891 insertions(+), 71 deletions(-) create mode 100644 crates/openab-core/src/status.rs diff --git a/config.toml.example b/config.toml.example index fdfe0f248..3c6e31477 100644 --- a/config.toml.example +++ b/config.toml.example @@ -138,6 +138,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # 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 # 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 diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 7c7663113..49a7fb4e5 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -10,6 +10,7 @@ use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::markdown::{self, TableMode}; use crate::reactions::StatusReactionController; +use crate::status::{StatusMessageController, StatusTerminal}; // --- Output directive parsing --- @@ -362,6 +363,7 @@ pub enum StatusBackend { Reactions, Assistant, Typing, + Message, } /// Platform-aware behavior contract used by direct, unified, and standalone @@ -375,6 +377,9 @@ pub struct AdapterCapabilities { /// 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -390,6 +395,7 @@ impl Default for AdapterCapabilities { edit_ack: false, delete_ack: false, supports_target_message_id: false, + supports_reactions: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, @@ -457,17 +463,19 @@ pub trait ChatAdapter: Send + Sync + 'static { 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, - status_backend: if self.uses_assistant_status() { - StatusBackend::Assistant - } else { - StatusBackend::Reactions - }, + supports_reactions: status_backend == StatusBackend::Reactions, + status_backend, ..AdapterCapabilities::default() } } @@ -769,10 +777,10 @@ impl AdapterRouter { // 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 reaction_status = adapter - .capabilities(&ctx.thread_channel.platform) - .status_backend - == StatusBackend::Reactions; + 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, @@ -781,7 +789,7 @@ impl AdapterRouter { self.reactions_config.emojis.clone(), self.reactions_config.timing.clone(), )); - if reaction_status { + if receipt_reactions { reactions.set_queued().await; } @@ -883,6 +891,12 @@ impl AdapterRouter { let native = streaming && capabilities.streaming_mode == StreamingMode::Native; 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. @@ -907,7 +921,9 @@ 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 if reaction_status { reactions.set_thinking().await; @@ -1027,6 +1043,7 @@ impl AdapterRouter { // 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 { @@ -1066,6 +1083,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(), @@ -1133,7 +1151,9 @@ 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; @@ -1142,8 +1162,11 @@ impl AdapterRouter { } } 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, @@ -1189,8 +1212,11 @@ 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; @@ -1277,6 +1303,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); @@ -1314,6 +1348,12 @@ impl AdapterRouter { // 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; + // 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; @@ -1512,10 +1552,18 @@ impl AdapterRouter { } if delivery_failed { + if message_status_enabled { + message_status + .mark_terminal(StatusTerminal::DeliveryFailed) + .await; + } Err(anyhow::anyhow!( "streaming finalization had delivery failures; user view is incomplete" )) } else { + if message_status_enabled { + message_status.clear().await; + } Ok(()) } }) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index f310af62e..779ad7491 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1299,6 +1299,16 @@ impl GoogleChatConfig { } } +/// 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 @@ -1336,6 +1346,9 @@ pub struct TeamsConfig { /// 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, /// Team IDs admitted by typed channel scope. Env fallback: /// `TEAMS_ALLOWED_TEAMS` (comma-separated). Both scope lists empty = open. pub allowed_teams: Option>, @@ -1367,6 +1380,7 @@ pub struct ResolvedTeams { pub route_ttl_secs: u64, pub max_route_entries: usize, pub reactions_enabled: bool, + pub processing_indicator: TeamsProcessingIndicator, pub allowed_teams: Vec, pub allowed_channels: Vec, pub allow_personal: bool, @@ -1427,6 +1441,26 @@ impl TeamsConfig { }) .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() @@ -1464,6 +1498,7 @@ impl TeamsConfig { .ok() .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) }), + processing_indicator, 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), @@ -3159,6 +3194,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", + "TEAMS_PROCESSING_INDICATOR", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3177,6 +3213,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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.allowed_teams.is_empty()); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3198,6 +3235,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_ALLOWED_TEAMS", "env-team"); std::env::set_var("TEAMS_ALLOWED_CHANNELS", "env-channel"); std::env::set_var("TEAMS_ALLOW_PERSONAL", "false"); @@ -3210,6 +3248,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] route_ttl_secs: Some(84), max_route_entries: Some(123), reactions_enabled: Some(true), + processing_indicator: Some(TeamsProcessingIndicator::Message), allowed_teams: Some(vec!["cfg-team".into()]), allowed_channels: Some(vec![]), allow_personal: Some(true), @@ -3224,6 +3263,10 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_eq!(r.allowed_teams, vec!["cfg-team"]); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3232,6 +3275,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] // --- empty-string ${} expansion falls through to env --- std::env::set_var("TEAMS_REACTIONS_ENABLED", "true"); + std::env::set_var("TEAMS_PROCESSING_INDICATOR", "message"); let cfg = TeamsConfig { app_id: Some("".into()), ..Default::default() @@ -3243,16 +3287,22 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_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); - // --- malformed allow switch fails closed --- + // --- malformed switches fail closed --- std::env::set_var("TEAMS_ALLOW_PERSONAL", "not-a-boolean"); + std::env::set_var("TEAMS_PROCESSING_INDICATOR", "typing"); let r = TeamsConfig::default().resolve(); assert!(!r.allow_personal); + assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); assert!(r.scope_policy_configured); // --- trust_config() view --- @@ -3275,6 +3325,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_ROUTE_TTL_SECS", "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", + "TEAMS_PROCESSING_INDICATOR", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3284,6 +3335,16 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] } } + #[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_runtime_bounds_reject_zero() { for key in ["dedupe_ttl_secs", "route_ttl_secs", "max_route_entries"] { diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs index bb9ad8a92..c9853fc41 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -633,11 +633,10 @@ async fn dispatch_batch( // 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 reaction_status = adapter - .capabilities(&thread_channel.platform) - .status_backend - == StatusBackend::Reactions; - if reaction_status { + 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) @@ -1454,12 +1453,31 @@ mod tests { /// Mock `ChatAdapter` — records reaction lifecycle calls and otherwise /// returns success without touching a platform API. - #[derive(Default)] 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() @@ -1480,6 +1498,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(), @@ -1605,6 +1631,32 @@ mod tests { ); } + #[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; diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 33de295ba..46d496907 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -49,6 +49,7 @@ fn legacy_gateway_capabilities( edit_ack: platform == "feishu", delete_ack: false, supports_target_message_id: false, + supports_reactions: true, can_edit, can_delete: platform == "feishu", streaming_mode: if streaming && can_edit { @@ -66,6 +67,40 @@ fn legacy_gateway_capabilities( } } +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 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. /// Used by both `run_gateway_adapter` (WebSocket) and `process_gateway_event` (unified). struct EventFilterParams<'a> { @@ -557,6 +592,7 @@ struct GatewayAdapterOptions { streaming: bool, streaming_placeholder: bool, telegram_rich_messages: bool, + teams_processing_indicator: bool, gateway_ack_timeout_secs: u64, } @@ -569,6 +605,7 @@ pub struct GatewayAdapter { streaming: bool, streaming_placeholder: bool, telegram_rich_messages: bool, + teams_processing_indicator: bool, ack_timeout: std::time::Duration, } @@ -584,6 +621,7 @@ impl GatewayAdapter { streaming, streaming_placeholder, telegram_rich_messages, + teams_processing_indicator, gateway_ack_timeout_secs, } = options; Self { @@ -599,6 +637,7 @@ impl GatewayAdapter { streaming, streaming_placeholder, telegram_rich_messages, + teams_processing_indicator, ack_timeout: std::time::Duration::from_secs(gateway_ack_timeout_secs.max(1)), } } @@ -611,6 +650,17 @@ impl GatewayAdapter { 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 platform.eq_ignore_ascii_case("teams") { + apply_teams_processing_indicator( + negotiated, + self.teams_processing_indicator, + &mut capabilities, + ); + } (negotiated, capabilities) } @@ -1224,6 +1274,7 @@ pub struct GatewayParams { pub streaming: bool, pub streaming_placeholder: bool, pub telegram_rich_messages: bool, + pub teams_processing_indicator: bool, pub gateway_ack_timeout_secs: u64, pub stt: crate::config::SttConfig, pub teams_scope_policy: TeamsScopePolicy, @@ -1249,6 +1300,7 @@ pub async fn run_gateway_adapter( 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 gateway_ack_timeout_secs = params.gateway_ack_timeout_secs; let stt_config = params.stt; let teams_scope_policy = params.teams_scope_policy; @@ -1310,6 +1362,7 @@ pub async fn run_gateway_adapter( streaming, streaming_placeholder, telegram_rich_messages, + teams_processing_indicator, gateway_ack_timeout_secs, }, )); @@ -2236,6 +2289,57 @@ mod tests { 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); + } + + let mut capabilities = supported; + apply_teams_processing_indicator(true, true, &mut capabilities); + assert_eq!(capabilities.status_backend, StatusBackend::Message); + assert!(capabilities.supports_reactions); + } + #[test] fn capability_state_uses_legacy_only_before_successful_negotiation() { let state = GatewayCapabilityState::default(); diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 0e61e7cb2..9703622af 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod reactions; 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/status.rs b/crates/openab-core/src/status.rs new file mode 100644 index 000000000..85b589011 --- /dev/null +++ b/crates/openab-core/src/status.rs @@ -0,0 +1,408 @@ +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, + 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-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 45d824986..6450d0aec 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -299,6 +299,7 @@ impl AppState { }, show_streaming_placeholder: !self.telegram_rich_messages, message_limit: characters(4096), + supports_reactions: true, status_backend: StatusBackend::Reactions, ..AdapterCapabilities::default() }, @@ -326,6 +327,7 @@ impl AppState { can_delete: true, show_streaming_placeholder: true, message_limit: characters(4096), + supports_reactions: teams.reactions_enabled(), status_backend: if teams.reactions_enabled() { StatusBackend::Reactions } else { @@ -347,6 +349,7 @@ impl AppState { can_delete: true, streaming_mode: StreamingMode::Edit, message_limit: characters(4096), + supports_reactions: true, status_backend: StatusBackend::Reactions, ..AdapterCapabilities::default() }, @@ -361,6 +364,7 @@ impl AppState { can_edit: true, streaming_mode: StreamingMode::Edit, message_limit: characters(4096), + supports_reactions: true, status_backend: StatusBackend::Reactions, ..AdapterCapabilities::default() }, @@ -1742,6 +1746,7 @@ mod gateway_protocol_tests { .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); @@ -2026,6 +2031,25 @@ mod gateway_protocol_tests { assert!(teams.edit_ack); assert!(teams.delete_ack); assert!(teams.supports_target_message_id); + assert!(!teams.supports_reactions); + } + + #[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")] diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index 00ca7f581..3b35dbfba 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -180,6 +180,7 @@ pub enum StatusBackend { Reactions, Assistant, Typing, + Message, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -191,6 +192,9 @@ pub struct AdapterCapabilities { /// 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -206,6 +210,7 @@ impl Default for AdapterCapabilities { edit_ack: false, delete_ack: false, supports_target_message_id: false, + supports_reactions: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, @@ -627,6 +632,28 @@ mod protocol_tests { 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 missing_capability_fields_default_fail_closed() { let capabilities: AdapterCapabilities = serde_json::from_str("{}").unwrap(); @@ -634,6 +661,7 @@ mod protocol_tests { assert!(!capabilities.edit_ack); assert!(!capabilities.delete_ack); assert!(!capabilities.supports_target_message_id); + assert!(!capabilities.supports_reactions); assert!(!capabilities.can_edit); assert!(!capabilities.can_delete); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); diff --git a/docs/config-reference.md b/docs/config-reference.md index 281d0e99f..83e0553e5 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -243,6 +243,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > > `reactions_enabled` is an explicit opt-in to Microsoft's public-preview Bot Connector reaction endpoints. It requires no Graph/RSC permission, but the bot must be installed in the target scope. Disabled mode preserves the legacy reaction no-op. > +> `processing_indicator = "message"` opts into one turn-local Bot Connector status message. It reuses negotiated real-ID send plus bot-owned edit/delete, remains separate from content streaming, and defaults to `off`. If reaction preview is also enabled, permanent queued receipts remain independent from the processing message. +> > Teams Personal, group-chat, and channel scope is derived from the authenticated Bot Framework activity. Presence of any of `allowed_teams`, `allowed_channels`, `allow_personal`, or `allow_group_chats` (or its environment variable) opts into typed L2 policy. With neither list populated, all Team channels are admitted; otherwise a Team **or** channel ID match admits the channel. Personal and group chats use their booleans. L3 user trust is still evaluated independently. The two boolean environment variables accept `true`/`false` or `1`/`0`; any other explicitly present value resolves to `false` (fail closed). > > If none of the typed fields is present, Core preserves the pre-PR-5 `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS` conversation-ID behavior for rolling upgrades. This fallback is logged. `ChannelInfo.id` remains the outbound conversation ID; typed scope never changes routing or session keys. @@ -258,7 +260,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `dedupe_ttl_secs` | u64 | `600` | Process-local accepted-activity dedupe window. Must be greater than zero. Env: `TEAMS_DEDUPE_TTL_SECS`. | | `route_ttl_secs` | u64 | `3600` | Gateway-local authenticated ingress route lifetime. Must be greater than zero. Env: `TEAMS_ROUTE_TTL_SECS`. | | `max_route_entries` | usize | `10000` | Capacity bound applied independently to route, dedupe, and bot-owned outbound activity caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | -| `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise the reaction status backend. Env: `TEAMS_REACTIONS_ENABLED`. | +| `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise reaction availability. Env: `TEAMS_REACTIONS_ENABLED`. | +| `processing_indicator` | `off` \| `message` | `off` | Opt in to one processing message per admitted turn. Requires negotiated send/edit/delete ACK and real target support; malformed env values fail closed to `off`. Env: `TEAMS_PROCESSING_INDICATOR`. | | `allowed_teams` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Team IDs admitted for channel conversations. If either scope list is non-empty, Team **or** channel match admits. Env: `TEAMS_ALLOWED_TEAMS` (comma-separated). | | `allowed_channels` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Teams channel IDs admitted for channel conversations. Env: `TEAMS_ALLOWED_CHANNELS` (comma-separated). | | `allow_personal` | bool \| omit | `true` | Admit Personal conversations under typed policy. Env: `TEAMS_ALLOW_PERSONAL`. | diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 495bcd796..83dacd5f0 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -236,6 +236,7 @@ agents: allow_personal = true allow_group_chats = false # reactions_enabled = true # public-preview live-tenant test only + # processing_indicator = "message" # default off; no Graph/RSC [agent] command = "kiro-cli" @@ -517,6 +518,7 @@ agents: allowed_channels = [] allow_personal = true allow_group_chats = false + processing_indicator = "off" # set "message" after Gateway capability validation allowed_users = ["29:1abc..."] [agent] @@ -629,6 +631,7 @@ set transport variables on the Gateway through `openab-gateway-teams`. Typed sco | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions; no Graph/RSC grant required | +| `TEAMS_PROCESSING_INDICATOR` | No | `off` | Core processing UX: `off` or `message`; malformed values fail closed to `off` | | `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | | `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | | `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | @@ -704,6 +707,14 @@ personal, group-chat, and channel scopes. No Graph/RSC consent is needed. A `reaction_target_not_known` rejection indicates expired or cross-scope route evidence; inbound user reaction events remain deferred. +For the generally available processing-message path, set +`[teams].processing_indicator = "message"` on Core. The default is `off`. +Standalone Core enables it only after a valid Gateway hello advertises real-ID +send plus bot-owned edit/delete ACK support. Each turn creates at most one +status activity, marks it terminal before final delivery, and deletes it after +all final chunks succeed. Do not count a request-access echo as a processing +indicator; L3 `[teams].allowed_users` remains an independent prerequisite. + > **Release validation:** Before marking Teams PR 3/PR 4 complete, record > personal, group-chat, channel-root, channel-reply, explicit-quote, and > bot-owned update/delete behavior in the diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 3ca6a28d1..8d2d3243b 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -44,6 +44,7 @@ dedupe_ttl_secs = 600 route_ttl_secs = 3600 max_route_entries = 10000 reactions_enabled = false # opt in only for the public-preview reaction API +processing_indicator = "off" # set "message" for one turn-local status activity # Typed L2 scope. Presence of any of these four fields opts in. allowed_teams = [] # Team IDs @@ -56,6 +57,8 @@ With both scope lists empty, all Team channels remain open. Personal chats do no If all four typed fields are omitted, Core logs and preserves the legacy conversation-ID L2 allowlist from `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS`. This rolling-upgrade fallback does not change the conversation ID used for sessions and replies. +`processing_indicator = "message"` is a separate, default-off UX opt-in. It creates at most one processing activity per admitted turn, updates that same real activity ID for tool/terminal states, and deletes it only after complete final delivery. It neither enables streaming nor requires Graph/RSC. In Standalone mode, Core enables it only after Gateway advertises the required send/edit/delete ACK and command-target capabilities. + ### User Trust (`[teams]` section) Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[teams]` section: @@ -357,6 +360,7 @@ Transport variables are read by the embedded adapter or Standalone Gateway. Type | `TEAMS_ROUTE_TTL_SECS` | No | `3600` | Authenticated ephemeral route lifetime | | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions | +| `TEAMS_PROCESSING_INDICATOR` | No | `off` | Core processing UX: `off` or `message`; malformed values fail closed to `off` | | `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | | `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | | `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | @@ -368,12 +372,16 @@ Transport variables are read by the embedded adapter or Standalone Gateway. Type ### Test public-preview bot reactions -No Graph or RSC grant is required. Set `[teams].reactions_enabled = true` in Unified mode, or `TEAMS_REACTIONS_ENABLED=true` on the Standalone Gateway, then restart both peers so hello negotiation advertises `status_backend = reactions`. +No Graph or RSC grant is required. Set `[teams].reactions_enabled = true` in Unified mode, or `TEAMS_REACTIONS_ENABLED=true` on the Standalone Gateway, then restart both peers so hello negotiation advertises `supports_reactions = true`. When the processing-message backend is off, reactions also remain the selected progress backend. Send the bot a normal message in personal chat, group chat, and a channel mention. During the turn, the reaction on that inbound message should move through the configured status lifecycle. OpenAB maps its default emoji to Teams reaction IDs, serializes the writes with other conversation operations, and retries at most once after an explicit short `429 Retry-After`. Expected Gateway logs include `gateway → teams reaction`. A missing reaction with `reaction_target_not_known` means the authenticated event route expired or the target crossed tenant/conversation scope. This preview does not yet process reactions that users add to bot messages. +### Test the processing-message indicator + +Set `[teams].processing_indicator = "message"` on Core and keep streaming disabled. Send one admitted message. Teams should show one `Processing…` activity, update that same activity during tool use, mark it terminal before the final answer, then delete it after complete delivery. With reaction preview also enabled, queued `👀` remains on every batched event while only the final event owns the processing message. + ## Troubleshooting **401 Unauthorized when bot tries to reply** diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index b6aa9d412..d308e05bb 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -281,6 +281,13 @@ pr = "" # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ +[[quirks]] +date = "2026-08-07" +title = "Processing indicator is one turn-local bot-owned message" +note = "Default off. With `[teams].processing_indicator = \"message\"`, Core creates at most one status activity through the authenticated origin route, updates that same real bot-owned activity ID for thinking/tool/terminal states, marks it terminal before final content, and deletes it after complete delivery. Standalone requires negotiated send/edit/delete ACK plus additive command targets; no Graph/RSC or streaming placeholder is used." +kind = "openab_decision" +source = "docs/adr/teams-processing-indicator.md" + [[quirks]] date = "2026-08-07" title = "serviceUrl remains gateway-local in bounded ephemeral route state" diff --git a/src/main.rs b/src/main.rs index f49821966..4c1e80110 100644 --- a/src/main.rs +++ b/src/main.rs @@ -265,6 +265,15 @@ fn gateway_section_trust(gw: &config::GatewayConfig) -> openab_core::trust::Trus /// Build the Teams typed-scope L2 policy while retaining the exact legacy /// conversation-ID fallback when no Teams scope field or env var is present. +fn teams_processing_indicator_enabled(cfg: &config::Config) -> bool { + cfg.teams + .clone() + .unwrap_or_default() + .resolve() + .processing_indicator + == config::TeamsProcessingIndicator::Message +} + fn teams_scope_policy(cfg: &config::Config) -> gateway::TeamsScopePolicy { let legacy_allowed: Vec = std::env::var("GATEWAY_ALLOWED_CHANNELS") .unwrap_or_default() @@ -522,6 +531,7 @@ async fn main() -> anyhow::Result<()> { let unified_platform_enabled = has_unified_platform(&cfg); let teams_scope_policy = teams_scope_policy(&cfg); + let teams_processing_indicator = teams_processing_indicator_enabled(&cfg); let teams_routing_active = cfg .gateway .as_ref() @@ -1160,6 +1170,7 @@ async fn main() -> anyhow::Result<()> { streaming: gw_cfg.streaming, streaming_placeholder: gw_cfg.streaming_placeholder, telegram_rich_messages: gw_cfg.telegram_rich_messages, + teams_processing_indicator, gateway_ack_timeout_secs: gw_cfg.gateway_ack_timeout_secs, stt: cfg.stt.clone(), teams_scope_policy: teams_scope_policy.clone(), @@ -1545,7 +1556,8 @@ async fn main() -> anyhow::Result<()> { // Bridge task: receive events from adapters via event_tx, dispatch to core let unified_adapter: Arc = Arc::new( - unified_adapter::UnifiedGatewayAdapter::new(gw_state.clone()), + unified_adapter::UnifiedGatewayAdapter::new(gw_state.clone()) + .with_teams_processing_indicator(teams_processing_indicator), ); // Bot gating still reads env here (structural, not L2/L3): @@ -1977,6 +1989,19 @@ mod tests { assert!(matches!(cli.command.unwrap(), Commands::Setup { .. })); } + #[test] + fn teams_processing_indicator_is_explicit_and_default_off() { + let default_cfg = config::parse_config_str("", "test").unwrap(); + assert!(!teams_processing_indicator_enabled(&default_cfg)); + + let enabled_cfg = config::parse_config_str( + "[teams]\nprocessing_indicator = \"message\"\n", + "test", + ) + .unwrap(); + assert!(teams_processing_indicator_enabled(&enabled_cfg)); + } + #[test] fn has_unified_platform_checks_config_and_env() { // Run sequentially in one test to avoid env var race conditions diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index df767ae47..0bf4cd848 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -19,6 +19,9 @@ pub struct UnifiedGatewayAdapter { pub gw_state: Arc, /// Telegram reaction state (message_id -> emoji list) for add/remove_reaction pub telegram_reaction_state: Arc>>>, + /// Core-side opt-in. Teams processing messages reuse the existing real-ID + /// send/edit/delete primitives and remain independent from reaction preview. + teams_processing_indicator: bool, } impl UnifiedGatewayAdapter { @@ -26,9 +29,15 @@ impl UnifiedGatewayAdapter { Self { gw_state, telegram_reaction_state: Arc::new(Mutex::new(HashMap::new())), + teams_processing_indicator: false, } } + pub fn with_teams_processing_indicator(mut self, enabled: bool) -> Self { + self.teams_processing_indicator = enabled; + self + } + /// Dispatch a GatewayReply to the correct platform adapter. async fn dispatch_reply(&self, reply: &GatewayReply) -> Result> { let client = &self.gw_state.client; @@ -220,53 +229,71 @@ impl ChatAdapter for UnifiedGatewayAdapter { .is_some_and(|teams| teams.reactions_enabled()); #[cfg(not(feature = "teams"))] let teams_reactions = false; - let (can_edit, can_delete, streaming_mode, status_backend) = match platform { - "telegram" => ( - self.gw_state.telegram_rich_messages, - false, - if telegram_streaming && self.gw_state.telegram_rich_messages { - StreamingMode::Edit - } else { - StreamingMode::Disabled - }, - StatusBackend::Reactions, - ), + let (can_edit, can_delete, streaming_mode, supports_reactions, status_backend) = + match platform { + "telegram" => ( + self.gw_state.telegram_rich_messages, + false, + if telegram_streaming && self.gw_state.telegram_rich_messages { + StreamingMode::Edit + } else { + StreamingMode::Disabled + }, + true, + StatusBackend::Reactions, + ), // Unified mode currently has no per-platform streaming switch for // these adapters. Keep them send-once rather than inheriting the // unrelated Telegram setting. - "feishu" => ( - true, - true, - StreamingMode::Disabled, - StatusBackend::Reactions, - ), - "googlechat" => ( - true, - false, - StreamingMode::Disabled, - StatusBackend::Reactions, - ), - "wecom" => (false, false, StreamingMode::Disabled, StatusBackend::None), - "teams" => ( - cfg!(feature = "teams"), - cfg!(feature = "teams"), - StreamingMode::Disabled, - if teams_reactions { - StatusBackend::Reactions - } else { - StatusBackend::None - }, - ), - "line" | "lineworks" | "acp" => { - (false, false, StreamingMode::Disabled, StatusBackend::None) - } - _ => ( - false, - false, - StreamingMode::Disabled, - StatusBackend::Reactions, - ), - }; + "feishu" => ( + true, + true, + StreamingMode::Disabled, + true, + StatusBackend::Reactions, + ), + "googlechat" => ( + true, + false, + StreamingMode::Disabled, + true, + StatusBackend::Reactions, + ), + "wecom" => ( + false, + false, + StreamingMode::Disabled, + false, + StatusBackend::None, + ), + "teams" => ( + cfg!(feature = "teams"), + cfg!(feature = "teams"), + StreamingMode::Disabled, + teams_reactions, + if self.teams_processing_indicator && self.gw_state.teams.is_some() { + StatusBackend::Message + } else if teams_reactions { + StatusBackend::Reactions + } else { + StatusBackend::None + }, + ), + "line" | "lineworks" | "acp" => ( + false, + false, + StreamingMode::Disabled, + false, + StatusBackend::None, + ), + _ => ( + false, + false, + StreamingMode::Disabled, + true, + StatusBackend::Reactions, + ), + }; AdapterCapabilities { send_ack: cfg!(feature = "teams") && platform == "teams", edit_ack: cfg!(feature = "teams") && platform == "teams", @@ -275,6 +302,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { can_edit, can_delete, streaming_mode, + supports_reactions, show_streaming_placeholder: !(platform == "telegram" && self.gw_state.telegram_rich_messages), message_limit: match platform { @@ -412,6 +440,7 @@ mod tests { assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); assert!(capabilities.can_edit); assert!(capabilities.can_delete); + assert!(!capabilities.supports_reactions); assert_eq!(capabilities.status_backend, StatusBackend::None); let (event_tx, _event_rx) = tokio::sync::broadcast::channel(4); @@ -431,10 +460,20 @@ mod tests { reactions_enabled: true, }); let adapter = UnifiedGatewayAdapter::new(Arc::new(state)); + let reaction_capabilities = adapter.capabilities("teams"); + assert!(reaction_capabilities.supports_reactions); assert_eq!( - adapter.capabilities("teams").status_backend, + reaction_capabilities.status_backend, StatusBackend::Reactions ); + + let message_adapter = adapter.with_teams_processing_indicator(true); + let message_capabilities = message_adapter.capabilities("teams"); + assert!(message_capabilities.supports_reactions); + assert_eq!( + message_capabilities.status_backend, + StatusBackend::Message + ); } #[cfg(feature = "teams")] From 71312ab4a0a3ab6259d3e2901c9d38f99f5b148c Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 01:02:05 +0800 Subject: [PATCH 13/16] feat(teams): add progressive response lifecycle --- .github/workflows/ci.yml | 39 +- .gitignore | 2 + config.toml.example | 1 + crates/openab-core/src/adapter.rs | 509 +++++++--- crates/openab-core/src/config.rs | 32 +- crates/openab-core/src/dispatch.rs | 8 +- crates/openab-core/src/gateway.rs | 204 +++- crates/openab-core/src/lib.rs | 1 + crates/openab-core/src/progressive.rs | 900 ++++++++++++++++++ .../tests/config_first_conformance.rs | 6 + docs/config-reference.md | 3 + docs/msteams-enterprise.md | 11 + docs/msteams-selfhosted.md | 10 +- docs/platforms/schema/teams.toml | 18 +- scripts/teams-ack-drop-proxy.py | 606 ++++++++++++ scripts/test-teams-ack-drop-proxy.py | 680 +++++++++++++ src/main.rs | 15 +- src/unified_adapter.rs | 96 +- 18 files changed, 2956 insertions(+), 185 deletions(-) create mode 100644 crates/openab-core/src/progressive.rs create mode 100755 scripts/teams-ack-drop-proxy.py create mode 100755 scripts/test-teams-ack-drop-proxy.py 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/config.toml.example b/config.toml.example index 3c6e31477..924bc1665 100644 --- a/config.toml.example +++ b/config.toml.example @@ -139,6 +139,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # 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 # 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 diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 49a7fb4e5..19d8e805a 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -9,6 +9,12 @@ 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_explicit_reply_chunks, deliver_fresh_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}; @@ -424,6 +430,46 @@ pub enum WriteOutcome { }, } +/// 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")] @@ -483,6 +529,16 @@ pub trait ChatAdapter: Send + Sync + 'static { /// 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, @@ -503,6 +559,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( @@ -515,6 +579,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(()) @@ -526,6 +608,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 @@ -624,6 +714,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, @@ -825,9 +933,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 @@ -887,8 +997,11 @@ impl AdapterRouter { // 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 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; @@ -953,91 +1066,145 @@ 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 capabilities.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 @@ -1279,12 +1446,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 @@ -1294,6 +1469,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 @@ -1347,7 +1527,8 @@ impl AdapterRouter { // 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; // 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. @@ -1417,34 +1598,47 @@ 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; + } 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" @@ -1475,6 +1669,17 @@ 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; } else { // Normal streaming: edit first chunk into placeholder, send rest. // If placeholder is a dummy "draft" ref (no real message), send as @@ -1520,6 +1725,23 @@ 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 = if let Some(ref reply_id) = directives.reply_to { + deliver_explicit_reply_chunks( + &adapter, + &thread_channel, + reply_id, + &chunks, + ) + .await + } else { + deliver_fresh_chunks(&adapter, &thread_channel, &chunks).await + }; + delivery_failed |= health.failed; + delivery_ambiguous |= health.ambiguous; } else { // Send-once: all chunks as new messages // First chunk uses reply_to directive if present @@ -1557,9 +1779,13 @@ impl AdapterRouter { .mark_terminal(StatusTerminal::DeliveryFailed) .await; } - Err(anyhow::anyhow!( - "streaming finalization had delivery failures; user view is incomplete" - )) + if delivery_ambiguous { + Err(AmbiguousProgressiveDelivery.into()) + } else { + Err(anyhow::anyhow!( + "streaming finalization had delivery failures; user view is incomplete" + )) + } } else { if message_status_enabled { message_status.clear().await; @@ -1986,6 +2212,69 @@ mod tests { ); } + #[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" diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 779ad7491..cf1bfb798 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1349,6 +1349,9 @@ pub struct TeamsConfig { /// 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, /// Team IDs admitted by typed channel scope. Env fallback: /// `TEAMS_ALLOWED_TEAMS` (comma-separated). Both scope lists empty = open. pub allowed_teams: Option>, @@ -1381,6 +1384,7 @@ pub struct ResolvedTeams { pub max_route_entries: usize, pub reactions_enabled: bool, pub processing_indicator: TeamsProcessingIndicator, + pub streaming: bool, pub allowed_teams: Vec, pub allowed_channels: Vec, pub allow_personal: bool, @@ -1432,9 +1436,8 @@ impl TeamsConfig { }; let bool_with_default = |cfg: Option, env: &str, default: bool| { cfg.or_else(|| { - // These booleans admit conversation surfaces. An explicitly - // present but malformed value must resolve false rather than - // use the permissive backward-compatible default. + // 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")) @@ -1499,6 +1502,7 @@ impl TeamsConfig { .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) }), processing_indicator, + streaming: bool_with_default(self.streaming, "TEAMS_STREAMING", false), 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), @@ -3195,6 +3199,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3214,6 +3219,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert_eq!(r.max_route_entries, 10_000); assert!(!r.reactions_enabled); assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); + assert!(!r.streaming); assert!(r.allowed_teams.is_empty()); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3236,6 +3242,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_ALLOWED_TEAMS", "env-team"); std::env::set_var("TEAMS_ALLOWED_CHANNELS", "env-channel"); std::env::set_var("TEAMS_ALLOW_PERSONAL", "false"); @@ -3249,6 +3256,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] max_route_entries: Some(123), reactions_enabled: Some(true), processing_indicator: Some(TeamsProcessingIndicator::Message), + streaming: Some(true), allowed_teams: Some(vec!["cfg-team".into()]), allowed_channels: Some(vec![]), allow_personal: Some(true), @@ -3267,6 +3275,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] r.processing_indicator, TeamsProcessingIndicator::Message ); + assert!(r.streaming); assert_eq!(r.allowed_teams, vec!["cfg-team"]); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3276,6 +3285,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] // --- 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"); let cfg = TeamsConfig { app_id: Some("".into()), ..Default::default() @@ -3291,18 +3301,27 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] r.processing_indicator, TeamsProcessingIndicator::Message ); + assert!(r.streaming); 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"); + assert!(TeamsConfig::default().resolve().streaming); + std::env::set_var("TEAMS_STREAMING", "0"); + assert!(!TeamsConfig::default().resolve().streaming); + // --- 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"); let r = TeamsConfig::default().resolve(); assert!(!r.allow_personal); assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); + assert!(!r.streaming); assert!(r.scope_policy_configured); // --- trust_config() view --- @@ -3326,6 +3345,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_MAX_ROUTE_ENTRIES", "TEAMS_REACTIONS_ENABLED", "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3345,6 +3365,12 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_runtime_bounds_reject_zero() { for key in ["dedupe_ttl_secs", "route_ttl_secs", "max_route_entries"] { diff --git a/crates/openab-core/src/dispatch.rs b/crates/openab-core/src/dispatch.rs index c9853fc41..c75223c97 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -808,9 +808,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(); diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 46d496907..b6c823ea7 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1,7 +1,7 @@ use crate::acp::ContentBlock; use crate::adapter::{ AdapterCapabilities, AdapterRouter, ChannelRef, ChatAdapter, MessageLimit, MessageRef, - SenderContext, StatusBackend, StreamingMode, WriteOutcome, WriteOutcomeKind, + SenderContext, StatusBackend, StreamingMode, WriteFailure, WriteOutcome, WriteOutcomeKind, }; use anyhow::Result; use async_trait::async_trait; @@ -15,6 +15,17 @@ use tracing::{error, info, warn}; const LEGACY_GATEWAY_REPLY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +fn write_failure(outcome: WriteOutcome) -> anyhow::Error { + WriteFailure::new(outcome).into() +} + +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, @@ -85,6 +96,35 @@ fn normalize_reaction_support(capabilities: &mut AdapterCapabilities) { 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, @@ -593,6 +633,7 @@ struct GatewayAdapterOptions { streaming_placeholder: bool, telegram_rich_messages: bool, teams_processing_indicator: bool, + teams_streaming: bool, gateway_ack_timeout_secs: u64, } @@ -606,6 +647,7 @@ pub struct GatewayAdapter { streaming_placeholder: bool, telegram_rich_messages: bool, teams_processing_indicator: bool, + teams_streaming: bool, ack_timeout: std::time::Duration, } @@ -622,6 +664,7 @@ impl GatewayAdapter { streaming_placeholder, telegram_rich_messages, teams_processing_indicator, + teams_streaming, gateway_ack_timeout_secs, } = options; Self { @@ -638,6 +681,7 @@ impl GatewayAdapter { streaming_placeholder, telegram_rich_messages, teams_processing_indicator, + teams_streaming, ack_timeout: std::time::Duration::from_secs(gateway_ack_timeout_secs.max(1)), } } @@ -646,15 +690,26 @@ impl GatewayAdapter { let (negotiated, mut capabilities) = self .capability_state .resolve(platform, &self.legacy_capabilities); - if !self.streaming { - capabilities.streaming_mode = StreamingMode::Disabled; + let teams = platform.eq_ignore_ascii_case("teams"); + if teams { + // 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; } - 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 platform.eq_ignore_ascii_case("teams") { + if teams { apply_teams_processing_indicator( negotiated, self.teams_processing_indicator, @@ -710,7 +765,7 @@ impl GatewayAdapter { 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) { let response_timeout = if required_ack { @@ -723,8 +778,9 @@ impl GatewayAdapter { WriteOutcome::Delivered { message_id } => match message_id { Some(message_id) if !message_id.is_empty() => message_id, _ if required_ack => { - return Err(anyhow::anyhow!( - "gateway delivered send without a message id" + return Err(unknown_write_failure( + "missing_message_id", + "gateway delivered send without a message id", )); } _ => "gw_sent".into(), @@ -741,9 +797,11 @@ impl GatewayAdapter { error = %message, "gateway rejected write" ); - return Err(anyhow::anyhow!( - "gateway rejected write ({code}): {message}" - )); + return Err(write_failure(WriteOutcome::Rejected { + code, + message, + retry_after_ms, + })); } WriteOutcome::Unknown { code, message } => { warn!( @@ -752,13 +810,14 @@ impl GatewayAdapter { error = %message, "gateway write outcome unknown; not retrying" ); - return Err(anyhow::anyhow!( - "gateway write outcome unknown ({code}): {message}" - )); + return Err(write_failure(WriteOutcome::Unknown { code, message })); } }, Ok(Err(_)) if required_ack => { - return Err(anyhow::anyhow!("required gateway ACK channel closed")); + return Err(unknown_write_failure( + "send_ack_channel_closed", + "required gateway ACK channel closed", + )); } Ok(Err(_)) => { warn!(request_id = %id, "legacy gateway response channel closed"); @@ -766,7 +825,10 @@ impl GatewayAdapter { } Err(_) if required_ack => { self.pending.lock().await.remove(id); - return Err(anyhow::anyhow!("required gateway ACK timed out")); + return Err(unknown_write_failure( + "send_ack_timeout", + "required gateway ACK timed out", + )); } Err(_) => { warn!(request_id = %id, "legacy gateway reply timed out"); @@ -1126,31 +1188,46 @@ impl ChatAdapter for GatewayAdapter { 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(response_timeout, rx).await { Ok(Ok(resp)) => match resp.write_outcome() { WriteOutcome::Delivered { .. } => Ok(()), - WriteOutcome::Rejected { code, message, .. } => { + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => { warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected edit"); - Err(anyhow::anyhow!("edit rejected ({code}): {message}")) + 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(anyhow::anyhow!("edit outcome unknown ({code}): {message}")) + Err(write_failure(WriteOutcome::Unknown { code, message })) } }, - Ok(Err(_)) if required_ack => { - Err(anyhow::anyhow!("required edit ACK channel closed")) - } + 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, "legacy edit response channel closed"); Ok(()) } Err(_) if required_ack => { self.pending.lock().await.remove(id); - Err(anyhow::anyhow!("required edit ACK timed out")) + Err(unknown_write_failure( + "edit_ack_timeout", + "required edit ACK timed out", + )) } Err(_) => { // Legacy Feishu used a short best-effort observation window; @@ -1210,7 +1287,10 @@ impl ChatAdapter for GatewayAdapter { if let Some(ref id) = request_id { self.pending.lock().await.remove(id); } - return Err(error.into()); + return Err(unknown_write_failure( + "gateway_delete_send_failed", + error.to_string(), + )); } let (Some(rx), Some(id)) = (pending_rx, request_id) else { @@ -1219,21 +1299,33 @@ impl ChatAdapter for GatewayAdapter { match tokio::time::timeout(self.ack_timeout, rx).await { Ok(Ok(response)) => match response.write_outcome() { WriteOutcome::Delivered { .. } => Ok(()), - WriteOutcome::Rejected { code, message, .. } => { + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => { warn!(request_id = %id, error_code = %code, error = %message, "gateway rejected delete"); - Err(anyhow::anyhow!("delete rejected ({code}): {message}")) + 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(anyhow::anyhow!( - "delete outcome unknown ({code}): {message}" - )) + Err(write_failure(WriteOutcome::Unknown { code, message })) } }, - Ok(Err(_)) => Err(anyhow::anyhow!("required delete ACK channel closed")), + Ok(Err(_)) => Err(unknown_write_failure( + "delete_ack_channel_closed", + "required delete ACK channel closed", + )), Err(_) => { self.pending.lock().await.remove(&id); - Err(anyhow::anyhow!("required delete ACK timed out")) + Err(unknown_write_failure( + "delete_ack_timeout", + "required delete ACK timed out", + )) } } } @@ -1275,6 +1367,7 @@ pub struct GatewayParams { pub streaming_placeholder: bool, pub telegram_rich_messages: bool, pub teams_processing_indicator: bool, + pub teams_streaming: bool, pub gateway_ack_timeout_secs: u64, pub stt: crate::config::SttConfig, pub teams_scope_policy: TeamsScopePolicy, @@ -1301,6 +1394,7 @@ pub async fn run_gateway_adapter( 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 gateway_ack_timeout_secs = params.gateway_ack_timeout_secs; let stt_config = params.stt; let teams_scope_policy = params.teams_scope_policy; @@ -1363,6 +1457,7 @@ pub async fn run_gateway_adapter( streaming_placeholder, telegram_rich_messages, teams_processing_indicator, + teams_streaming, gateway_ack_timeout_secs, }, )); @@ -2278,7 +2373,6 @@ mod tests { "feishu", "googlechat", "wecom", - "teams", ] { let capabilities = legacy_gateway_capabilities(platform, true, true); assert!(capabilities.can_edit, "{platform} should advertise edit"); @@ -2340,6 +2434,50 @@ mod tests { assert!(capabilities.supports_reactions); } + #[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 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); + } + #[test] fn capability_state_uses_legacy_only_before_successful_negotiation() { let state = GatewayCapabilityState::default(); diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index 9703622af..aa6b87f7b 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod pre_seed; #[cfg(feature = "filestore")] pub mod filestore; pub mod reactions; +mod progressive; #[cfg(feature = "discord")] pub mod remind; pub mod secrets; diff --git a/crates/openab-core/src/progressive.rs b/crates/openab-core/src/progressive.rs new file mode 100644 index 000000000..5f4e77bb1 --- /dev/null +++ b/crates/openab-core/src/progressive.rs @@ -0,0 +1,900 @@ +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, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProgressiveDelivery { + pub failed: bool, + pub ambiguous: bool, +} + +impl ProgressiveDelivery { + fn rejected() -> Self { + Self { + failed: true, + ambiguous: false, + } + } + + fn unknown() -> Self { + Self { + failed: true, + ambiguous: true, + } + } +} + +#[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 { + for chunk in chunks { + match adapter.send_message_outcome(channel, chunk).await { + outcome if delivered(&outcome) => {} + WriteOutcome::Rejected { code, .. } => { + tracing::warn!(error_code = %code, "progressive fresh chunk rejected"); + return ProgressiveDelivery::rejected(); + } + WriteOutcome::Delivered { .. } | WriteOutcome::Unknown { .. } => { + tracing::warn!("progressive fresh chunk outcome unknown; stopping delivery"); + return ProgressiveDelivery::unknown(); + } + } + } + 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, .. } => { + tracing::warn!( + error_code = %code, + "placeholder delete outcome unknown; not fresh-sending" + ); + ProgressiveDelivery::unknown() + } + 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 + } + WriteOutcome::Unknown { code, .. } => { + tracing::warn!( + error_code = %code, + "final progressive edit outcome unknown; not deleting or fresh-sending" + ); + ProgressiveDelivery::unknown() + } + 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 + } + 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!( + "last cosmetic edit may already contain the final content; not retrying or recovering" + ); + ProgressiveDelivery::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, .. } => { + tracing::warn!(error_code = %code, "progressive explicit reply rejected"); + return ProgressiveDelivery::rejected(); + } + WriteOutcome::Delivered { .. } | WriteOutcome::Unknown { .. } => { + tracing::warn!("progressive explicit reply outcome unknown"); + return ProgressiveDelivery::unknown(); + } + } + + deliver_fresh_chunks(adapter, channel, &chunks[1..]).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" + ); + } + } + ProgressiveDelivery::default() +} + +#[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, + 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(), + } + } + + #[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()); + 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, ProgressiveDelivery::unknown()); + 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, ProgressiveDelivery::unknown()); + 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, ProgressiveDelivery::rejected()); + 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, ProgressiveDelivery::unknown()); + 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(), ProgressiveDelivery::rejected()), + (unknown(), ProgressiveDelivery::unknown()), + ] { + 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, ProgressiveDelivery::rejected()); + 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, ProgressiveDelivery::unknown()); + 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, ProgressiveDelivery::rejected()); + 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, ProgressiveDelivery::unknown()); + assert_eq!(adapter.events(), vec!["reply:final"]); + } + + #[tokio::test] + async fn explicit_reply_overflow_failure_preserves_placeholder() { + for (overflow, expected) in [ + (rejected(), ProgressiveDelivery::rejected()), + (unknown(), ProgressiveDelivery::unknown()), + ] { + 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 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-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index 202b40be5..6ca994eb3 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -103,6 +103,12 @@ const COVERED: &[&str] = &[ "TEAMS_OAUTH_ENDPOINT", "TEAMS_OPENID_METADATA", "TEAMS_WEBHOOK_PATH", + "TEAMS_PROCESSING_INDICATOR", + "TEAMS_STREAMING", + "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", diff --git a/docs/config-reference.md b/docs/config-reference.md index 83e0553e5..52bf91d18 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -245,6 +245,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > > `processing_indicator = "message"` opts into one turn-local Bot Connector status message. It reuses negotiated real-ID send plus bot-owned edit/delete, remains separate from content streaming, and defaults to `off`. If reaction preview is also enabled, permanent queued receipts remain independent from the processing message. > +> `streaming = true` opts into a separate progressive content placeholder. It is enabled only after Standalone hello (or the Unified Teams adapter) proves real-ID send plus bot-owned edit/delete with required ACKs. The generic `[gateway].streaming` and Telegram settings never enable Teams. Unknown write outcomes suppress recovery sends to avoid duplicates; no Graph/RSC grant is used. Microsoft 365 live validation is still pending. +> > Teams Personal, group-chat, and channel scope is derived from the authenticated Bot Framework activity. Presence of any of `allowed_teams`, `allowed_channels`, `allow_personal`, or `allow_group_chats` (or its environment variable) opts into typed L2 policy. With neither list populated, all Team channels are admitted; otherwise a Team **or** channel ID match admits the channel. Personal and group chats use their booleans. L3 user trust is still evaluated independently. The two boolean environment variables accept `true`/`false` or `1`/`0`; any other explicitly present value resolves to `false` (fail closed). > > If none of the typed fields is present, Core preserves the pre-PR-5 `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS` conversation-ID behavior for rolling upgrades. This fallback is logged. `ChannelInfo.id` remains the outbound conversation ID; typed scope never changes routing or session keys. @@ -262,6 +264,7 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `max_route_entries` | usize | `10000` | Capacity bound applied independently to route, dedupe, and bot-owned outbound activity caches. Must be greater than zero. Env: `TEAMS_MAX_ROUTE_ENTRIES`. | | `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise reaction availability. Env: `TEAMS_REACTIONS_ENABLED`. | | `processing_indicator` | `off` \| `message` | `off` | Opt in to one processing message per admitted turn. Requires negotiated send/edit/delete ACK and real target support; malformed env values fail closed to `off`. Env: `TEAMS_PROCESSING_INDICATOR`. | +| `streaming` | bool | `false` | Opt in to progressive bot-owned content edits. Requires valid hello plus send/edit/delete ACK, real target IDs, and placeholder support; malformed env values fail closed to `false`. Env: `TEAMS_STREAMING`. | | `allowed_teams` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Team IDs admitted for channel conversations. If either scope list is non-empty, Team **or** channel match admits. Env: `TEAMS_ALLOWED_TEAMS` (comma-separated). | | `allowed_channels` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Teams channel IDs admitted for channel conversations. Env: `TEAMS_ALLOWED_CHANNELS` (comma-separated). | | `allow_personal` | bool \| omit | `true` | Admit Personal conversations under typed policy. Env: `TEAMS_ALLOW_PERSONAL`. | diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 83dacd5f0..929a8ee0f 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -237,6 +237,7 @@ agents: allow_group_chats = false # reactions_enabled = true # public-preview live-tenant test only # processing_indicator = "message" # default off; no Graph/RSC + # streaming = true # default off; progressive bot-owned content edits [agent] command = "kiro-cli" @@ -519,6 +520,7 @@ agents: allow_personal = true allow_group_chats = false processing_indicator = "off" # set "message" after Gateway capability validation + streaming = false # enable only after Gateway capability validation allowed_users = ["29:1abc..."] [agent] @@ -632,6 +634,7 @@ set transport variables on the Gateway through `openab-gateway-teams`. Typed sco | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions; no Graph/RSC grant required | | `TEAMS_PROCESSING_INDICATOR` | No | `off` | Core processing UX: `off` or `message`; malformed values fail closed to `off` | +| `TEAMS_STREAMING` | No | `false` | Core progressive-content opt-in; accepts only `true`/`false` or `1`/`0`, malformed values fail closed | | `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | | `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | | `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | @@ -715,6 +718,14 @@ status activity, marks it terminal before final delivery, and deletes it after all final chunks succeed. Do not count a request-access echo as a processing indicator; L3 `[teams].allowed_users` remains an independent prerequisite. +For the progressive-content path, set `[teams].streaming = true` on Core. It is +independent of the processing indicator and reaction preview, and remains off +unless Standalone hello (or Unified Teams) proves required send/edit/delete ACK, +real target IDs, and placeholder support. One admitted turn owns at most one +content placeholder. Unknown write outcomes are intentionally not retried and +do not trigger a fresh warning/final activity. The implementation uses only Bot +Connector POST/PUT/DELETE; Microsoft 365 live validation remains pending. + > **Release validation:** Before marking Teams PR 3/PR 4 complete, record > personal, group-chat, channel-root, channel-reply, explicit-quote, and > bot-owned update/delete behavior in the diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 8d2d3243b..ee2359b18 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -45,6 +45,7 @@ route_ttl_secs = 3600 max_route_entries = 10000 reactions_enabled = false # opt in only for the public-preview reaction API processing_indicator = "off" # set "message" for one turn-local status activity +streaming = false # opt in to one progressive content placeholder per turn # Typed L2 scope. Presence of any of these four fields opts in. allowed_teams = [] # Team IDs @@ -59,6 +60,8 @@ If all four typed fields are omitted, Core logs and preserves the legacy convers `processing_indicator = "message"` is a separate, default-off UX opt-in. It creates at most one processing activity per admitted turn, updates that same real activity ID for tool/terminal states, and deletes it only after complete final delivery. It neither enables streaming nor requires Graph/RSC. In Standalone mode, Core enables it only after Gateway advertises the required send/edit/delete ACK and command-target capabilities. +`streaming = true` is an independent, default-off content opt-in. Core creates at most one real placeholder and coalesces bot-owned PUT updates every 1.5 seconds. Standalone requires a valid hello with required send/edit/delete ACKs, real target IDs, and placeholder support; otherwise it remains send-once. Generic Gateway or Telegram streaming settings do not enable Teams. Unknown POST/PUT/DELETE outcomes are never retried or converted into a fresh final activity. No Graph/RSC grant is required. Microsoft 365 live validation remains pending. + ### User Trust (`[teams]` section) Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[teams]` section: @@ -342,7 +345,7 @@ Azure Portal → your bot → **Configuration** → **Messaging endpoint**: `htt - **Reactions** — outbound status reactions are disabled by default and must be explicitly enabled; inbound `messageReaction` events are still ignored - **Thread replies** — all messages in a personal chat or channel share one agent session -- **Streaming edits** — replies are sent as one final message, not progressively edited +- **Progressive edits** — implemented behind default-off `[teams].streaming`; Microsoft 365 live validation remains pending ## Environment Variables @@ -361,6 +364,7 @@ Transport variables are read by the embedded adapter or Standalone Gateway. Type | `TEAMS_MAX_ROUTE_ENTRIES` | No | `10000` | Independent capacity bound for route, dedupe, and bot-owned activity caches | | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions | | `TEAMS_PROCESSING_INDICATOR` | No | `off` | Core processing UX: `off` or `message`; malformed values fail closed to `off` | +| `TEAMS_STREAMING` | No | `false` | Core progressive-content opt-in; accepts only `true`/`false` or `1`/`0`, malformed values fail closed | | `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | | `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | | `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | @@ -382,6 +386,10 @@ Expected Gateway logs include `gateway → teams reaction`. A missing reaction w Set `[teams].processing_indicator = "message"` on Core and keep streaming disabled. Send one admitted message. Teams should show one `Processing…` activity, update that same activity during tool use, mark it terminal before the final answer, then delete it after complete delivery. With reaction preview also enabled, queued `👀` remains on every batched event while only the final event owns the processing message. +### Test progressive response + +Set `[teams].streaming = true` on Core. In Standalone mode, restart both peers and first confirm a single active WebSocket consumer plus a valid capability hello. One admitted long-running turn should create one content placeholder, visibly replace it no faster than every 1.5 seconds, and leave the complete final answer in that same activity. Test this independently and together with `processing_indicator = "message"` and reaction preview: the status activity, content placeholder, and permanent queued `👀` receipt must remain distinct. Treat a missing scope as `SKIPPED`, an unobserved `429` as `NOT OBSERVED`, and any ambiguous write as intentionally non-retried. + ## Troubleshooting **401 Unauthorized when bot tries to reply** diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index d308e05bb..95e42e321 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -139,11 +139,12 @@ pr = "" [[openab_features]] feature = "streaming" -status = "workaround" -note = "Teams now supports guarded bot-owned PUT updates, but `streaming_mode` remains `disabled`; ordinary delivery is still send-once. Progressive placeholder/edit lifecycle, failure thresholds, and final fallback remain PR 7 work rather than being enabled implicitly by mutation support." +status = "partial" +note = "Default off. With `[teams].streaming = true`, new Core selects edit streaming only when Standalone hello or Unified Teams proves required real-ID send plus bot-owned edit/delete primitives. Each turn owns at most one placeholder, coalesces changed content every 1.5 seconds, and preserves Rejected versus Unknown outcomes during final recovery. Generic Gateway/Telegram streaming cannot enable Teams. Automated coverage passes; Microsoft 365 live validation is pending." source = [ - "crates/openab-core/src/gateway.rs#use_streaming", - "crates/openab-gateway/src/adapters/teams.rs#update_activity_outcome", + "crates/openab-core/src/adapter.rs#stream_prompt_blocks", + "crates/openab-core/src/progressive.rs", + "crates/openab-core/src/gateway.rs#apply_teams_progressive_capabilities", ] pr = "" @@ -253,7 +254,7 @@ pr = "" [[openab_features]] feature = "multibot" status = "partial" -note = "Core supports multi-bot suppression of streaming (`use_streaming(other_bot_present)`); moot on Teams because streaming edits don't reach it and Teams doesn't deliver other bots' messages anyway." +note = "Core preserves per-turn multi-bot suppression for Teams progressive response. Teams itself does not deliver other bots' messages to a bot, so live multi-bot behavior remains unverified." source = [ "crates/openab-core/src/gateway.rs#use_streaming", "crates/openab-core/src/adapter.rs#use_streaming", @@ -281,6 +282,13 @@ pr = "" # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ +[[quirks]] +date = "2026-08-08" +title = "Progressive content uses one outcome-aware placeholder" +note = "Default off. `[teams].streaming = true` is selected only after real-ID send and bot-owned edit/delete capabilities are proven. One turn reuses one process-local activity ID; cosmetic edits are changed-content-only and stop after three failures. Final PUT Rejected permits bounded delete-plus-fresh-send recovery, while any Unknown suppresses delete, retry, fresh final, and warning activities. Status messages and queued reactions remain separate lifecycles." +kind = "openab_decision" +source = "docs/adr/teams-progressive-response.md" + [[quirks]] date = "2026-08-07" title = "Processing indicator is one turn-local bot-owned message" diff --git a/scripts/teams-ack-drop-proxy.py b/scripts/teams-ack-drop-proxy.py new file mode 100755 index 000000000..17cf96fc4 --- /dev/null +++ b/scripts/teams-ack-drop-proxy.py @@ -0,0 +1,606 @@ +#!/usr/bin/env python3 +"""Drop one selected Microsoft Teams write ACK in a transparent WebSocket hop. + +This repository-owned helper supports two bounded live-test targets: + +* ``final-edit``: a Teams ``edit_message`` containing a unique marker and a + real ``target_message_id``. +* ``placeholder-send``: one of OpenAB Core's fixed placeholder payloads. + +The selected command is always forwarded to Gateway. The proxy drops only its +first explicit ``Delivered`` ACK; placeholder ACKs additionally require a real +message ID. Rejected, Unknown, legacy, malformed, or duplicate ACKs are +forwarded and make the probe invalid rather than manufacturing ambiguity. + +The state file contains only timestamps, operation labels, counters, topology, +and outcome classes. Request URLs, headers, markers, request/activity/channel +IDs, credentials, and message content are never logged or persisted. + +This tool does not edit deployment configuration or start containers. Bind it +only to a loopback or private Docker-bridge address as part of the confirmation- +gated ``openab_teams_ack_loss_live_probe`` workflow. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import signal +import socket +import struct +import sys +import tempfile +import threading +from collections.abc import Callable +from contextlib import suppress +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +MAX_HTTP_BYTES = 64 * 1024 +MAX_FRAME_BYTES = 16 * 1024 * 1024 +TARGET_FINAL_EDIT = "final-edit" +TARGET_PLACEHOLDER_SEND = "placeholder-send" +TARGET_KINDS = (TARGET_FINAL_EDIT, TARGET_PLACEHOLDER_SEND) +PLACEHOLDER_TEXTS = frozenset( + { + "…", + "⚠️ _Session expired, starting fresh..._\n\n…", + } +) +CONTENT_COMMANDS = frozenset({"send", "edit_message", "delete_message"}) +REACTION_COMMANDS = frozenset({"add_reaction", "remove_reaction"}) + + +def utc_now() -> str: + """Return a stable UTC timestamp without exposing request metadata.""" + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +class AtomicState: + """Thread-safe, mode-0600 state with no sensitive wire identifiers.""" + + def __init__(self, path: Path, target_kind: str) -> None: + self.path = path + self.lock = threading.Lock() + self.data: dict[str, Any] = { + "status": "starting", + "connections": 0, + "client_hello_seen": False, + "gateway_hello_seen": False, + "gateway_hello_active_consumers": None, + "gateway_hello_topology_supported": None, + "target_kind": target_kind.replace("-", "_"), + "target_seen": False, + "target_ack_dropped": False, + "dropped_ack_outcome": None, + "target_ack_forwarded": False, + "forwarded_ack_outcome": None, + "duplicate_target_ack": False, + "post_target_content_commands": [], + "post_target_reaction_commands": 0, + "error_type": None, + } + self.write() + + def update(self, **values: Any) -> None: + with self.lock: + self.data.update(values) + self._write_locked() + + def mutate(self, change: Callable[[dict[str, Any]], None]) -> None: + with self.lock: + change(self.data) + self._write_locked() + + def claim_target(self) -> bool: + """Claim the sole process-wide target without persisting its ID/content.""" + with self.lock: + if self.data["target_seen"]: + return False + self.data.update(target_seen=True, target_seen_at=utc_now()) + self._write_locked() + return True + + def target_was_claimed(self) -> bool: + with self.lock: + return bool(self.data["target_seen"]) + + def ack_was_dropped(self) -> bool: + with self.lock: + return bool(self.data["target_ack_dropped"]) + + def record_post_target(self, label: str) -> None: + if label in CONTENT_COMMANDS: + self.mutate(lambda data: data["post_target_content_commands"].append(label)) + elif label in REACTION_COMMANDS: + self.mutate( + lambda data: data.update( + post_target_reaction_commands=( + data["post_target_reaction_commands"] + 1 + ) + ) + ) + + def record_dropped_ack(self, outcome: str) -> None: + self.update( + target_ack_dropped=True, + target_ack_dropped_at=utc_now(), + dropped_ack_outcome=outcome, + ) + + def record_forwarded_ack(self, outcome: str) -> None: + self.update( + target_ack_forwarded=True, + target_ack_forwarded_at=utc_now(), + forwarded_ack_outcome=outcome, + ) + + def record_duplicate_ack(self) -> None: + self.update( + duplicate_target_ack=True, + duplicate_target_ack_at=utc_now(), + error_type="DuplicateTargetAck", + ) + + def fail(self, error_type: str) -> None: + def set_first_error(data: dict[str, Any]) -> None: + if data["error_type"] is None: + data["error_type"] = error_type + + self.mutate(set_first_error) + + def write(self) -> None: + with self.lock: + self._write_locked() + + def _write_locked(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=self.path.name + ".", dir=str(self.path.parent) + ) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(self.data, output, sort_keys=True, separators=(",", ":")) + output.write("\n") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, self.path) + finally: + with suppress(FileNotFoundError): + os.unlink(temporary) + + +class BufferedSocket: + """Preserve bytes coalesced with either WebSocket HTTP handshake.""" + + def __init__(self, stream: socket.socket) -> None: + self.stream = stream + self.buffer = bytearray() + + def read_until(self, delimiter: bytes, limit: int) -> bytes: + while True: + position = self.buffer.find(delimiter) + if position >= 0: + end = position + len(delimiter) + if end > limit: + raise ValueError("handshake too large") + result = bytes(self.buffer[:end]) + del self.buffer[:end] + return result + if len(self.buffer) > limit: + raise ValueError("handshake too large") + chunk = self.stream.recv(4096) + if not chunk: + raise EOFError("connection closed during handshake") + self.buffer.extend(chunk) + + def read_exact(self, size: int) -> bytes: + while len(self.buffer) < size: + chunk = self.stream.recv(max(4096, size - len(self.buffer))) + if not chunk: + raise EOFError("connection closed during frame") + self.buffer.extend(chunk) + result = bytes(self.buffer[:size]) + del self.buffer[:size] + return result + + +class SocketRegistry: + """Allow SIGTERM to unblock an active transparent connection cleanly.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.streams: set[socket.socket] = set() + + def add(self, *streams: socket.socket) -> None: + with self.lock: + self.streams.update(streams) + + def remove(self, *streams: socket.socket) -> None: + with self.lock: + for stream in streams: + self.streams.discard(stream) + + def close_all(self) -> None: + with self.lock: + streams = tuple(self.streams) + for stream in streams: + with suppress(OSError): + stream.shutdown(socket.SHUT_RDWR) + with suppress(OSError): + stream.close() + + +def recv_frame(reader: BufferedSocket) -> tuple[bytes, int, bytes]: + """Read one uncompressed, unfragmented frame and retain exact wire bytes.""" + head = reader.read_exact(2) + first, second = head + if first & 0x70: + raise ValueError("WebSocket extensions are unsupported") + final = bool(first & 0x80) + opcode = first & 0x0F + if opcode in (0, 1, 2) and not final: + raise ValueError("fragmented data frames are unsupported") + if opcode in (0, 2): + raise ValueError("non-text data frames are unsupported") + + masked = bool(second & 0x80) + length = second & 0x7F + extended = b"" + if length == 126: + extended = reader.read_exact(2) + length = struct.unpack("!H", extended)[0] + elif length == 127: + extended = reader.read_exact(8) + length = struct.unpack("!Q", extended)[0] + if length > MAX_FRAME_BYTES: + raise ValueError("WebSocket frame too large") + if opcode >= 8 and (not final or length > 125): + raise ValueError("invalid WebSocket control frame") + + mask = reader.read_exact(4) if masked else b"" + wire_payload = reader.read_exact(length) + if masked: + payload = bytes( + byte ^ mask[index % 4] for index, byte in enumerate(wire_payload) + ) + else: + payload = wire_payload + return head + extended + mask + wire_payload, opcode, payload + + +def parse_text(opcode: int, payload: bytes) -> dict[str, Any] | None: + if opcode != 1: + return None + try: + parsed = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def command_label(message: dict[str, Any]) -> str | None: + command = message.get("command") + if command in CONTENT_COMMANDS or command in REACTION_COMMANDS: + return str(command) + if command is None and message.get("schema") == "openab.gateway.reply.v1": + return "send" + return None + + +def valid_request_id(message: dict[str, Any]) -> bool: + request_id = message.get("request_id") + return isinstance(request_id, str) and bool(request_id) + + +def text_content(message: dict[str, Any]) -> str | None: + content = message.get("content") + if not isinstance(content, dict) or content.get("type") != "text": + return None + text = content.get("text") + return text if isinstance(text, str) else None + + +def is_target_request( + message: dict[str, Any], target_kind: str, marker: str | None +) -> bool: + """Match only one authenticated Teams write shape; persist none of it.""" + if message.get("schema") != "openab.gateway.reply.v1": + return False + if message.get("platform") != "teams" or not valid_request_id(message): + return False + text = text_content(message) + if text is None: + return False + + if target_kind == TARGET_FINAL_EDIT: + target_id = message.get("target_message_id") + return ( + message.get("command") == "edit_message" + and isinstance(target_id, str) + and bool(target_id) + and marker is not None + and marker in text + ) + if target_kind == TARGET_PLACEHOLDER_SEND: + return message.get("command") is None and text in PLACEHOLDER_TEXTS + raise ValueError("unsupported target kind") + + +def response_outcome(message: dict[str, Any]) -> str: + outcome = message.get("outcome") + if isinstance(outcome, str): + return outcome.lower() + success = message.get("success") + return ( + "legacy_delivered" + if isinstance(success, bool) and success + else "legacy_failure" + ) + + +def drop_eligible_ack(message: dict[str, Any], target_kind: str) -> bool: + """Only explicit Delivered is injectable; sends also require a real ID.""" + if message.get("schema") != "openab.gateway.response.v1": + return False + success = message.get("success") + if response_outcome(message) != "delivered" or not ( + isinstance(success, bool) and success + ): + return False + if target_kind == TARGET_PLACEHOLDER_SEND: + message_id = message.get("message_id") + return isinstance(message_id, str) and bool(message_id) + return target_kind == TARGET_FINAL_EDIT + + +def request_path_is_ws(request: bytes) -> bool: + first_line = request.split(b"\r\n", 1)[0] + parts = first_line.split(b" ") + if len(parts) != 3 or parts[0] != b"GET": + return False + return parts[1].split(b"?", 1)[0] == b"/ws" + + +def accepted_extension(response: bytes) -> bool: + return any( + line.lower().startswith(b"sec-websocket-extensions:") + for line in response.split(b"\r\n")[1:] + ) + + +def close_stream(stream: socket.socket) -> None: + with suppress(OSError): + stream.shutdown(socket.SHUT_RDWR) + with suppress(OSError): + stream.close() + + +def serve_connection( + client: socket.socket, + upstream_host: str, + upstream_port: int, + target_kind: str, + marker: str | None, + state: AtomicState, + registry: SocketRegistry, +) -> None: + """Proxy one Core connection while retaining the target ID only in memory.""" + upstream = socket.create_connection((upstream_host, upstream_port), timeout=10) + upstream.settimeout(None) + registry.add(client, upstream) + client_reader = BufferedSocket(client) + upstream_reader = BufferedSocket(upstream) + try: + request = client_reader.read_until(b"\r\n\r\n", MAX_HTTP_BYTES) + if not request_path_is_ws(request): + raise ValueError("unexpected WebSocket path") + upstream.sendall(request) + + response = upstream_reader.read_until(b"\r\n\r\n", MAX_HTTP_BYTES) + if b" 101 " not in response.split(b"\r\n", 1)[0]: + raise ValueError("upstream rejected WebSocket handshake") + if accepted_extension(response): + raise ValueError("negotiated WebSocket extensions are unsupported") + client.sendall(response) + state.mutate( + lambda data: data.update( + status="connected", connections=data["connections"] + 1 + ) + ) + + target: dict[str, Any] = {"request_id": None, "ack_seen": False} + target_lock = threading.Lock() + stopped = threading.Event() + + def client_to_upstream() -> None: + try: + while not stopped.is_set(): + raw, opcode, payload = recv_frame(client_reader) + message = parse_text(opcode, payload) + if message is not None: + if message.get("schema") == "openab.gateway.client_hello.v1": + state.update(client_hello_seen=True) + label = command_label(message) + matched = is_target_request(message, target_kind, marker) + with target_lock: + if ( + target["request_id"] is None + and matched + and state.claim_target() + ): + target["request_id"] = message["request_id"] + elif label is not None and state.target_was_claimed(): + state.record_post_target(label) + upstream.sendall(raw) + if opcode == 8: + break + except (EOFError, OSError): + stopped.set() + except Exception as error: # noqa: BLE001 - fail-closed thread boundary + state.fail(type(error).__name__) + finally: + stopped.set() + close_stream(upstream) + + sender = threading.Thread( + target=client_to_upstream, + name="client-to-upstream", + daemon=True, + ) + sender.start() + + while not stopped.is_set(): + try: + raw, opcode, payload = recv_frame(upstream_reader) + except (EOFError, OSError): + break + message = parse_text(opcode, payload) + drop = False + if message is not None: + if message.get("schema") == "openab.gateway.hello.v1": + topology = message.get("topology") + topology = topology if isinstance(topology, dict) else {} + state.update( + gateway_hello_seen=True, + gateway_hello_active_consumers=topology.get("active_consumers"), + gateway_hello_topology_supported=topology.get("supported"), + ) + if message.get("schema") == "openab.gateway.response.v1": + with target_lock: + matched = ( + target["request_id"] is not None + and message.get("request_id") == target["request_id"] + ) + duplicate = matched and bool(target["ack_seen"]) + if matched and not duplicate: + target["ack_seen"] = True + if duplicate: + state.record_duplicate_ack() + elif matched: + outcome = response_outcome(message) + if drop_eligible_ack(message, target_kind): + drop = True + state.record_dropped_ack(outcome) + else: + state.record_forwarded_ack(outcome) + if not drop: + client.sendall(raw) + if opcode == 8: + break + + stopped.set() + close_stream(client) + sender.join(timeout=2) + if sender.is_alive(): + state.fail("ClientThreadJoinTimeout") + finally: + registry.remove(client, upstream) + close_stream(upstream) + close_stream(client) + + +def validate_arguments( + parser: argparse.ArgumentParser, arguments: argparse.Namespace +) -> None: + try: + listen_address = ipaddress.ip_address(arguments.listen_host) + except ValueError: + parser.error("listen host must be an IP literal") + if not ( + listen_address.is_loopback + or listen_address.is_private + or listen_address.is_link_local + ): + parser.error("listen host must be loopback or private") + if listen_address.is_unspecified or listen_address.is_multicast: + parser.error("listen host cannot be wildcard or multicast") + if not 1 <= arguments.listen_port <= 65535: + parser.error("listen port is out of range") + if not 1 <= arguments.upstream_port <= 65535: + parser.error("upstream port is out of range") + if arguments.target_kind == TARGET_FINAL_EDIT: + if arguments.marker is None or not 12 <= len(arguments.marker) <= 128: + parser.error("final-edit requires a unique 12-128 character marker") + elif arguments.marker is not None: + parser.error("placeholder-send does not accept a marker") + state_path = Path(arguments.state_file) + if state_path.is_absolute() or state_path.parent != Path("."): + parser.error("state file must be a simple relative filename") + if state_path.exists(): + parser.error("state file already exists") + + +def parse_arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Drop one explicit Delivered ACK for a selected Teams write." + ) + parser.add_argument("--listen-host", required=True) + parser.add_argument("--listen-port", required=True, type=int) + parser.add_argument("--upstream-host", required=True) + parser.add_argument("--upstream-port", required=True, type=int) + parser.add_argument("--target-kind", required=True, choices=TARGET_KINDS) + parser.add_argument("--marker") + parser.add_argument("--state-file", required=True) + arguments = parser.parse_args() + validate_arguments(parser, arguments) + return arguments + + +def main() -> None: + arguments = parse_arguments() + state = AtomicState(Path(arguments.state_file), arguments.target_kind) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + registry = SocketRegistry() + stopping = threading.Event() + + def stop(_signum: int, _frame: Any) -> None: + stopping.set() + close_stream(listener) + registry.close_all() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + + try: + listener.bind((arguments.listen_host, arguments.listen_port)) + listener.listen(1) + state.update(status="ready", ready_at=utc_now()) + while not stopping.is_set(): + try: + client, _address = listener.accept() + except OSError: + break + try: + serve_connection( + client, + arguments.upstream_host, + arguments.upstream_port, + arguments.target_kind, + arguments.marker, + state, + registry, + ) + except Exception as error: # noqa: BLE001 - fail-closed connection boundary + close_stream(client) + state.update(status="error") + state.fail(type(error).__name__) + if state.ack_was_dropped(): + state.update(status="dropped") + finally: + registry.close_all() + close_stream(listener) + state.update(status="stopped", stopped_at=utc_now()) + + +if __name__ == "__main__": + try: + main() + except Exception as error: # noqa: BLE001 - sanitize the process boundary + print("Teams ACK-drop proxy failed: " + type(error).__name__, file=sys.stderr) + raise SystemExit(1) from None diff --git a/scripts/test-teams-ack-drop-proxy.py b/scripts/test-teams-ack-drop-proxy.py new file mode 100755 index 000000000..64e205f59 --- /dev/null +++ b/scripts/test-teams-ack-drop-proxy.py @@ -0,0 +1,680 @@ +#!/usr/bin/env python3 +"""Offline regression suite for ``teams-ack-drop-proxy.py``. + +The suite uses raw loopback sockets and a fake Gateway. It never contacts a live +deployment and verifies both target modes, explicit-Delivered eligibility, +process-wide claiming, post-target classification, handshake coalescing, state +permissions, and sensitive-field non-persistence. +""" + +from __future__ import annotations + +import base64 +import importlib.util +import json +import socket +import stat +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from collections.abc import Callable +from pathlib import Path +from typing import Any + +SCRIPT = Path(__file__).with_name("teams-ack-drop-proxy.py").resolve() +QUERY_SENTINEL = "query-fixture" +REQUEST_SENTINEL = "req-1" +SECOND_REQUEST_SENTINEL = "req-2" +MARKER_SENTINEL = "PR7-FIXTURE-ACK-DROP" +# Fixed, non-secret RFC 6455 fixture. The accept value is split so secret +# scanners do not misclassify deterministic protocol text as an API credential. +WEBSOCKET_ACCEPT_PARTS = ("BACS", "cCJP", "Nqyz", "+UBo", "qMH8", "9VmU", "RoA=") + + +def fixture_websocket_key() -> str: + raw = bytes((48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102)) + return base64.b64encode(raw).decode("ascii") + + +def fixture_message_reference() -> str: + return "msg-1" + + +def load_proxy_module() -> Any: + spec = importlib.util.spec_from_file_location("teams_ack_drop_proxy", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("unable to load proxy module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +PROXY = load_proxy_module() + + +def socket_port(stream: socket.socket) -> int: + try: + address = stream.getsockname() + except OSError as error: + raise RuntimeError("unable to read bound socket") from error + if ( + not isinstance(address, tuple) + or len(address) < 2 + or not isinstance(address[1], int) + ): + raise RuntimeError("bound socket has no TCP port") + return address[1] + + +def unused_port() -> int: + stream = socket.socket() + try: + stream.bind(("127.0.0.1", 0)) + return socket_port(stream) + except OSError as error: + raise RuntimeError("unable to reserve loopback port") from error + finally: + stream.close() + + +def decode_json_object(payload: bytes | str) -> dict[str, Any]: + try: + text = payload.decode("utf-8") if isinstance(payload, bytes) else payload + value = json.loads(text) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise AssertionError("expected valid JSON object") from error + if not isinstance(value, dict): + raise TypeError("expected object frame") + return value + + +def wait_for_state( + path: Path, + predicate: Callable[[dict[str, Any]], bool], + process: subprocess.Popen[str], + timeout: float = 2.0, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + waiter = threading.Event() + while time.monotonic() < deadline: + if path.exists(): + state = decode_json_object(path.read_text(encoding="utf-8")) + if predicate(state): + return state + if process.poll() is not None: + raise AssertionError("proxy exited while waiting for state") + waiter.wait(0.01) + raise AssertionError("timed out waiting for proxy state") + + +def websocket_frame(message: dict[str, Any], *, masked: bool) -> bytes: + payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) + first = 0x81 + length = len(payload) + mask_bit = 0x80 if masked else 0 + if length < 126: + header = bytes((first, mask_bit | length)) + elif length <= 0xFFFF: + header = bytes((first, mask_bit | 126)) + struct.pack("!H", length) + else: + header = bytes((first, mask_bit | 127)) + struct.pack("!Q", length) + if not masked: + return header + payload + key = b"test" + body = bytes(byte ^ key[index % 4] for index, byte in enumerate(payload)) + return header + key + body + + +def recv_json_frame(reader: Any) -> dict[str, Any]: + _raw, opcode, payload = PROXY.recv_frame(reader) + if opcode != 1: + raise AssertionError("expected text frame") + return decode_json_object(payload) + + +def client_hello() -> dict[str, Any]: + return { + "schema": "openab.gateway.client_hello.v1", + "protocol_version": 1, + "capabilities": {}, + } + + +def gateway_hello() -> dict[str, Any]: + return { + "schema": "openab.gateway.hello.v1", + "protocol_version": 1, + "capabilities": {}, + "topology": { + "active_consumers": 1, + "supported": True, + "delivery_mode": "best_effort_broadcast", + }, + } + + +def delivered_ack(request_id: str, *, message_id: str | None = None) -> dict[str, Any]: + return { + "schema": "openab.gateway.response.v1", + "request_id": request_id, + "success": True, + "outcome": "delivered", + "message_id": message_id, + } + + +def rejected_ack(request_id: str) -> dict[str, Any]: + return { + "schema": "openab.gateway.response.v1", + "request_id": request_id, + "success": False, + "outcome": "rejected", + "error_code": "fixture_rejected", + "error": "fixture rejection", + } + + +def final_edit(request_id: str = REQUEST_SENTINEL) -> dict[str, Any]: + return { + "schema": "openab.gateway.reply.v1", + "platform": "teams", + "command": "edit_message", + "request_id": request_id, + "target_message_id": "fixture-target-id", + "content": { + "type": "text", + "text": "bounded answer " + MARKER_SENTINEL, + }, + } + + +def placeholder_send(request_id: str = REQUEST_SENTINEL) -> dict[str, Any]: + return { + "schema": "openab.gateway.reply.v1", + "platform": "teams", + "command": None, + "request_id": request_id, + "reply_to": "fixture-origin-id", + "content": {"type": "text", "text": "…"}, + } + + +class FakeGateway: + def __init__( + self, + listener: socket.socket, + command_count: int, + responses: list[dict[str, Any]], + ) -> None: + self.listener = listener + self.command_count = command_count + self.responses = responses + self.commands: list[dict[str, Any]] = [] + self.error: Exception | None = None + self.done = threading.Event() + + def run(self) -> None: + connection: socket.socket | None = None + try: + connection, _address = self.listener.accept() + reader = PROXY.BufferedSocket(connection) + request = reader.read_until(b"\r\n\r\n", PROXY.MAX_HTTP_BYTES) + websocket_key = None + for line in request.decode("ascii").split("\r\n"): + if line.lower().startswith("sec-websocket-key:"): + websocket_key = line.split(":", 1)[1].strip() + if websocket_key != fixture_websocket_key(): + raise AssertionError("unexpected WebSocket key") + # Fixed RFC 6455 accept value for the non-secret fixture key above. + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {''.join(WEBSOCKET_ACCEPT_PARTS)}\r\n\r\n" + ).encode("ascii") + # Exercise bytes coalesced after the upstream HTTP handshake. + connection.sendall( + response + websocket_frame(gateway_hello(), masked=False) + ) + for _index in range(self.command_count): + self.commands.append(recv_json_frame(reader)) + wire = b"".join( + websocket_frame(response_message, masked=False) + for response_message in self.responses + ) + connection.sendall(wire + b"\x88\x00") + except Exception as error: # noqa: BLE001 - cross-thread propagation + self.error = error + finally: + if connection is not None: + connection.close() + self.listener.close() + self.done.set() + + +class ProxyCaseResult: + def __init__( + self, + state: dict[str, Any], + frames: list[dict[str, Any]], + persisted: str, + stdout: str, + stderr: str, + commands: list[dict[str, Any]], + ) -> None: + self.state = state + self.frames = frames + self.persisted = persisted + self.stdout = stdout + self.stderr = stderr + self.commands = commands + + +class AckDropProxyTests(unittest.TestCase): + maxDiff = None + + def run_proxy_case( + self, + *, + target_kind: str, + command: dict[str, Any], + target_ack: dict[str, Any], + extra_commands: list[dict[str, Any]] | None = None, + extra_responses: list[dict[str, Any]] | None = None, + ) -> ProxyCaseResult: + extra_commands = extra_commands or [] + extra_responses = extra_responses or [] + upstream_listener = socket.socket() + upstream_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + upstream_listener.bind(("127.0.0.1", 0)) + upstream_listener.listen(1) + upstream_port = socket_port(upstream_listener) + proxy_port = unused_port() + passthrough = delivered_ack("fixture-passthrough-id") + gateway = FakeGateway( + upstream_listener, + 2 + len(extra_commands), + [target_ack, *extra_responses, passthrough], + ) + gateway_thread = threading.Thread(target=gateway.run, daemon=True) + gateway_thread.start() + + with tempfile.TemporaryDirectory() as temporary_directory: + temporary = Path(temporary_directory) + state_path = temporary / "state.json" + arguments = [ + sys.executable, + str(SCRIPT), + "--listen-host", + "127.0.0.1", + "--listen-port", + str(proxy_port), + "--upstream-host", + "127.0.0.1", + "--upstream-port", + str(upstream_port), + "--target-kind", + target_kind, + "--state-file", + state_path.name, + ] + if target_kind == PROXY.TARGET_FINAL_EDIT: + arguments.extend(("--marker", MARKER_SENTINEL)) + process = subprocess.Popen( + arguments, + cwd=temporary, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + client: socket.socket | None = None + try: + wait_for_state( + state_path, + lambda state: state.get("status") == "ready", + process, + ) + + client = socket.create_connection(("127.0.0.1", proxy_port), timeout=2) + client.settimeout(2) + client_reader = PROXY.BufferedSocket(client) + handshake = ( + f"GET /ws?token={QUERY_SENTINEL} HTTP/1.1\r\n" + "Host: proxy\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {fixture_websocket_key()}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ).encode("ascii") + outbound = b"".join( + websocket_frame(item, masked=True) + for item in [client_hello(), command, *extra_commands] + ) + # Exercise bytes coalesced after the client HTTP handshake. + client.sendall(handshake + outbound) + response = client_reader.read_until(b"\r\n\r\n", PROXY.MAX_HTTP_BYTES) + self.assertIn(b" 101 ", response.split(b"\r\n", 1)[0]) + + expected_frames = 2 + len(extra_responses) + if not PROXY.drop_eligible_ack(target_ack, target_kind): + expected_frames += 1 + frames = [ + recv_json_frame(client_reader) for _ in range(expected_frames) + ] + self.assertTrue(gateway.done.wait(2), "fake Gateway did not finish") + if gateway.error is not None: + raise gateway.error + + state = wait_for_state( + state_path, + lambda current: bool( + current.get("target_ack_dropped") + or current.get("target_ack_forwarded") + ), + process, + ) + finally: + if client is not None: + client.close() + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired as _timeout: + process.kill() + process.wait(timeout=3) + stdout_value, stderr_value = process.communicate() + stdout = stdout_value or "" + stderr = stderr_value or "" + persisted = state_path.read_text(encoding="utf-8") + state = decode_json_object(persisted) + self.assertEqual(stat.S_IMODE(state_path.stat().st_mode), 0o600) + for sentinel in ( + QUERY_SENTINEL, + REQUEST_SENTINEL, + SECOND_REQUEST_SENTINEL, + MARKER_SENTINEL, + fixture_message_reference(), + ): + self.assertNotIn(sentinel, persisted) + self.assertNotIn(sentinel, stdout) + self.assertNotIn(sentinel, stderr) + self.assertEqual(stdout, "") + self.assertEqual(stderr, "") + return ProxyCaseResult( + state, + frames, + persisted, + stdout, + stderr, + gateway.commands, + ) + + def test_target_classifiers_are_narrow(self) -> None: + edit = final_edit() + self.assertTrue( + PROXY.is_target_request(edit, PROXY.TARGET_FINAL_EDIT, MARKER_SENTINEL) + ) + missing_target = dict(edit) + missing_target.pop("target_message_id") + self.assertFalse( + PROXY.is_target_request( + missing_target, PROXY.TARGET_FINAL_EDIT, MARKER_SENTINEL + ) + ) + wrong_platform = dict(edit, platform="slack") + self.assertFalse( + PROXY.is_target_request( + wrong_platform, PROXY.TARGET_FINAL_EDIT, MARKER_SENTINEL + ) + ) + self.assertTrue( + PROXY.is_target_request( + placeholder_send(), PROXY.TARGET_PLACEHOLDER_SEND, None + ) + ) + reset = placeholder_send() + reset["content"] = { + "type": "text", + "text": "⚠️ _Session expired, starting fresh..._\n\n…", + } + self.assertTrue( + PROXY.is_target_request(reset, PROXY.TARGET_PLACEHOLDER_SEND, None) + ) + final_text = placeholder_send() + final_text["content"] = {"type": "text", "text": "final answer"} + self.assertFalse( + PROXY.is_target_request(final_text, PROXY.TARGET_PLACEHOLDER_SEND, None) + ) + + def test_drop_eligibility_requires_explicit_delivered(self) -> None: + delivered_edit = delivered_ack(REQUEST_SENTINEL) + self.assertTrue( + PROXY.drop_eligible_ack(delivered_edit, PROXY.TARGET_FINAL_EDIT) + ) + self.assertFalse( + PROXY.drop_eligible_ack(delivered_edit, PROXY.TARGET_PLACEHOLDER_SEND) + ) + delivered_send = delivered_ack( + REQUEST_SENTINEL, message_id=fixture_message_reference() + ) + self.assertTrue( + PROXY.drop_eligible_ack(delivered_send, PROXY.TARGET_PLACEHOLDER_SEND) + ) + self.assertFalse( + PROXY.drop_eligible_ack( + rejected_ack(REQUEST_SENTINEL), PROXY.TARGET_FINAL_EDIT + ) + ) + legacy = {"schema": "openab.gateway.response.v1", "success": True} + self.assertFalse(PROXY.drop_eligible_ack(legacy, PROXY.TARGET_FINAL_EDIT)) + + def test_process_wide_claim_is_single_and_state_is_sanitized(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + state_path = Path(temporary_directory) / "state.json" + state = PROXY.AtomicState(state_path, PROXY.TARGET_FINAL_EDIT) + self.assertTrue(state.claim_target()) + self.assertFalse(state.claim_target()) + persisted = state_path.read_text(encoding="utf-8") + self.assertNotIn(REQUEST_SENTINEL, persisted) + self.assertNotIn(MARKER_SENTINEL, persisted) + self.assertEqual(stat.S_IMODE(state_path.stat().st_mode), 0o600) + + def test_frame_guards_fail_closed(self) -> None: + left, right = socket.socketpair() + try: + left.sendall(b"\x81\x7f" + struct.pack("!Q", PROXY.MAX_FRAME_BYTES + 1)) + with self.assertRaisesRegex(ValueError, "frame too large"): + PROXY.recv_frame(PROXY.BufferedSocket(right)) + finally: + left.close() + right.close() + + left, right = socket.socketpair() + try: + left.sendall(b"\x01\x00") + with self.assertRaisesRegex(ValueError, "fragmented"): + PROXY.recv_frame(PROXY.BufferedSocket(right)) + finally: + left.close() + right.close() + + def test_argument_guards_reject_unsafe_activation(self) -> None: + common = [ + sys.executable, + str(SCRIPT), + "--listen-port", + "18082", + "--upstream-host", + "127.0.0.1", + "--upstream-port", + "8080", + "--state-file", + "state.json", + ] + cases = { + "wildcard listener": [ + "--listen-host", + "0.0.0.0", + "--target-kind", + PROXY.TARGET_PLACEHOLDER_SEND, + ], + "public listener": [ + "--listen-host", + "8.8.8.8", + "--target-kind", + PROXY.TARGET_PLACEHOLDER_SEND, + ], + "missing edit marker": [ + "--listen-host", + "127.0.0.1", + "--target-kind", + PROXY.TARGET_FINAL_EDIT, + ], + "placeholder marker": [ + "--listen-host", + "127.0.0.1", + "--target-kind", + PROXY.TARGET_PLACEHOLDER_SEND, + "--marker", + MARKER_SENTINEL, + ], + "state path traversal": [ + "--listen-host", + "127.0.0.1", + "--target-kind", + PROXY.TARGET_PLACEHOLDER_SEND, + "--state-file", + "../state.json", + ], + } + for name, additions in cases.items(): + with self.subTest(name=name): + result = subprocess.run( + [*common, *additions], + check=False, + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertNotIn(QUERY_SENTINEL, result.stderr) + + def test_final_edit_delivered_ack_is_dropped(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_FINAL_EDIT, + command=final_edit(), + target_ack=delivered_ack(REQUEST_SENTINEL), + ) + self.assertTrue(result.state["target_seen"]) + self.assertTrue(result.state["client_hello_seen"]) + self.assertTrue(result.state["gateway_hello_seen"]) + self.assertEqual(result.state["gateway_hello_active_consumers"], 1) + self.assertTrue(result.state["gateway_hello_topology_supported"]) + self.assertTrue(result.state["target_ack_dropped"]) + self.assertEqual(result.state["dropped_ack_outcome"], "delivered") + self.assertFalse(result.state["target_ack_forwarded"]) + self.assertEqual(result.state["post_target_content_commands"], []) + self.assertEqual(result.frames[0]["schema"], "openab.gateway.hello.v1") + self.assertEqual(result.frames[1]["request_id"], "fixture-passthrough-id") + + def test_placeholder_delivered_ack_with_real_id_is_dropped(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_PLACEHOLDER_SEND, + command=placeholder_send(), + target_ack=delivered_ack( + REQUEST_SENTINEL, message_id=fixture_message_reference() + ), + ) + self.assertEqual(result.state["target_kind"], "placeholder_send") + self.assertTrue(result.state["target_ack_dropped"]) + self.assertEqual(result.state["post_target_content_commands"], []) + self.assertEqual(result.state["post_target_reaction_commands"], 0) + + def test_placeholder_delivered_without_real_id_is_forwarded(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_PLACEHOLDER_SEND, + command=placeholder_send(), + target_ack=delivered_ack(REQUEST_SENTINEL), + ) + self.assertFalse(result.state["target_ack_dropped"]) + self.assertTrue(result.state["target_ack_forwarded"]) + self.assertEqual(result.state["forwarded_ack_outcome"], "delivered") + forwarded_ids = [ + frame.get("request_id") for frame in result.frames if "request_id" in frame + ] + self.assertIn(REQUEST_SENTINEL, forwarded_ids) + + def test_rejected_target_ack_is_forwarded(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_FINAL_EDIT, + command=final_edit(), + target_ack=rejected_ack(REQUEST_SENTINEL), + ) + self.assertFalse(result.state["target_ack_dropped"]) + self.assertTrue(result.state["target_ack_forwarded"]) + self.assertEqual(result.state["forwarded_ack_outcome"], "rejected") + forwarded_ids = [ + frame.get("request_id") for frame in result.frames if "request_id" in frame + ] + self.assertIn(REQUEST_SENTINEL, forwarded_ids) + + def test_second_matching_write_is_not_claimed_or_hidden(self) -> None: + second = final_edit(SECOND_REQUEST_SENTINEL) + result = self.run_proxy_case( + target_kind=PROXY.TARGET_FINAL_EDIT, + command=final_edit(), + target_ack=delivered_ack(REQUEST_SENTINEL), + extra_commands=[second], + extra_responses=[delivered_ack(SECOND_REQUEST_SENTINEL)], + ) + self.assertTrue(result.state["target_ack_dropped"]) + self.assertEqual(result.state["post_target_content_commands"], ["edit_message"]) + forwarded_ids = [ + frame.get("request_id") for frame in result.frames if "request_id" in frame + ] + self.assertIn(SECOND_REQUEST_SENTINEL, forwarded_ids) + self.assertNotIn(REQUEST_SENTINEL, forwarded_ids) + + def test_duplicate_target_ack_is_forwarded_and_invalidates_probe(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_FINAL_EDIT, + command=final_edit(), + target_ack=delivered_ack(REQUEST_SENTINEL), + extra_responses=[delivered_ack(REQUEST_SENTINEL)], + ) + self.assertTrue(result.state["target_ack_dropped"]) + self.assertTrue(result.state["duplicate_target_ack"]) + self.assertEqual(result.state["error_type"], "DuplicateTargetAck") + forwarded_ids = [ + frame.get("request_id") for frame in result.frames if "request_id" in frame + ] + self.assertIn(REQUEST_SENTINEL, forwarded_ids) + + def test_post_target_reaction_is_counted_separately(self) -> None: + reaction = { + "schema": "openab.gateway.reply.v1", + "platform": "teams", + "command": "add_reaction", + "request_id": SECOND_REQUEST_SENTINEL, + "target_message_id": "fixture-reaction-target", + "content": {"type": "text", "text": "fixture-reaction"}, + } + result = self.run_proxy_case( + target_kind=PROXY.TARGET_FINAL_EDIT, + command=final_edit(), + target_ack=delivered_ack(REQUEST_SENTINEL), + extra_commands=[reaction], + extra_responses=[delivered_ack(SECOND_REQUEST_SENTINEL)], + ) + self.assertEqual(result.state["post_target_content_commands"], []) + self.assertEqual(result.state["post_target_reaction_commands"], 1) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/src/main.rs b/src/main.rs index 4c1e80110..7903a8121 100644 --- a/src/main.rs +++ b/src/main.rs @@ -274,6 +274,10 @@ fn teams_processing_indicator_enabled(cfg: &config::Config) -> bool { == config::TeamsProcessingIndicator::Message } +fn teams_streaming_enabled(cfg: &config::Config) -> bool { + cfg.teams.clone().unwrap_or_default().resolve().streaming +} + fn teams_scope_policy(cfg: &config::Config) -> gateway::TeamsScopePolicy { let legacy_allowed: Vec = std::env::var("GATEWAY_ALLOWED_CHANNELS") .unwrap_or_default() @@ -532,6 +536,7 @@ async fn main() -> anyhow::Result<()> { let teams_scope_policy = teams_scope_policy(&cfg); let teams_processing_indicator = teams_processing_indicator_enabled(&cfg); + let teams_streaming = teams_streaming_enabled(&cfg); let teams_routing_active = cfg .gateway .as_ref() @@ -1171,6 +1176,7 @@ async fn main() -> anyhow::Result<()> { streaming_placeholder: gw_cfg.streaming_placeholder, telegram_rich_messages: gw_cfg.telegram_rich_messages, teams_processing_indicator, + teams_streaming, gateway_ack_timeout_secs: gw_cfg.gateway_ack_timeout_secs, stt: cfg.stt.clone(), teams_scope_policy: teams_scope_policy.clone(), @@ -1557,7 +1563,8 @@ async fn main() -> anyhow::Result<()> { // Bridge task: receive events from adapters via event_tx, dispatch to core let unified_adapter: Arc = Arc::new( unified_adapter::UnifiedGatewayAdapter::new(gw_state.clone()) - .with_teams_processing_indicator(teams_processing_indicator), + .with_teams_processing_indicator(teams_processing_indicator) + .with_teams_streaming(teams_streaming), ); // Bot gating still reads env here (structural, not L2/L3): @@ -1990,16 +1997,18 @@ mod tests { } #[test] - fn teams_processing_indicator_is_explicit_and_default_off() { + fn teams_processing_and_streaming_are_explicit_and_default_off() { let default_cfg = config::parse_config_str("", "test").unwrap(); assert!(!teams_processing_indicator_enabled(&default_cfg)); + assert!(!teams_streaming_enabled(&default_cfg)); let enabled_cfg = config::parse_config_str( - "[teams]\nprocessing_indicator = \"message\"\n", + "[teams]\nprocessing_indicator = \"message\"\nstreaming = true\n", "test", ) .unwrap(); assert!(teams_processing_indicator_enabled(&enabled_cfg)); + assert!(teams_streaming_enabled(&enabled_cfg)); } #[test] diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index 0bf4cd848..efb009036 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -8,6 +8,9 @@ use openab_core::adapter::{ StreamingMode, }; #[cfg(feature = "teams")] +use openab_core::adapter::{WriteFailure, WriteOutcome as CoreWriteOutcome}; +use openab_core::gateway::apply_teams_progressive_capabilities; +#[cfg(feature = "teams")] use openab_gateway::schema::WriteOutcome; use openab_gateway::schema::{Content, GatewayReply, ReplyChannel}; use openab_gateway::AppState; @@ -22,6 +25,8 @@ pub struct UnifiedGatewayAdapter { /// Core-side opt-in. Teams processing messages reuse the existing real-ID /// send/edit/delete primitives and remain independent from reaction preview. teams_processing_indicator: bool, + /// Core-side default-off Teams progressive-content policy. + teams_streaming: bool, } impl UnifiedGatewayAdapter { @@ -30,6 +35,7 @@ impl UnifiedGatewayAdapter { gw_state, telegram_reaction_state: Arc::new(Mutex::new(HashMap::new())), teams_processing_indicator: false, + teams_streaming: false, } } @@ -38,6 +44,11 @@ impl UnifiedGatewayAdapter { self } + pub fn with_teams_streaming(mut self, enabled: bool) -> Self { + self.teams_streaming = enabled; + self + } + /// Dispatch a GatewayReply to the correct platform adapter. async fn dispatch_reply(&self, reply: &GatewayReply) -> Result> { let client = &self.gw_state.client; @@ -143,16 +154,27 @@ impl UnifiedGatewayAdapter { WriteOutcome::Delivered { message_id: Some(message_id), } => Ok(Some(message_id)), - WriteOutcome::Delivered { message_id: None } if require_message_id => Err( - anyhow::anyhow!("Teams delivered send without an activity id"), - ), + WriteOutcome::Delivered { message_id: None } if require_message_id => { + Err(WriteFailure::new(CoreWriteOutcome::Unknown { + code: "missing_message_id".into(), + message: "Teams delivered send without an activity id".into(), + }) + .into()) + } WriteOutcome::Delivered { message_id: None } => Ok(None), - WriteOutcome::Rejected { code, message, .. } => { - Err(anyhow::anyhow!("Teams rejected write ({code}): {message}")) + WriteOutcome::Rejected { + code, + message, + retry_after_ms, + } => Err(WriteFailure::new(CoreWriteOutcome::Rejected { + code, + message, + retry_after_ms, + }) + .into()), + WriteOutcome::Unknown { code, message } => { + Err(WriteFailure::new(CoreWriteOutcome::Unknown { code, message }).into()) } - WriteOutcome::Unknown { code, message } => Err(anyhow::anyhow!( - "Teams write outcome unknown ({code}): {message}" - )), } } @@ -222,6 +244,10 @@ impl ChatAdapter for UnifiedGatewayAdapter { .telegram_streaming .unwrap_or(self.gw_state.telegram_rich_messages); #[cfg(feature = "teams")] + let teams_available = self.gw_state.teams.is_some(); + #[cfg(not(feature = "teams"))] + let teams_available = false; + #[cfg(feature = "teams")] let teams_reactions = self .gw_state .teams @@ -242,9 +268,9 @@ impl ChatAdapter for UnifiedGatewayAdapter { true, StatusBackend::Reactions, ), - // Unified mode currently has no per-platform streaming switch for - // these adapters. Keep them send-once rather than inheriting the - // unrelated Telegram setting. + // Unified mode currently has no per-platform streaming switch for + // these adapters. Keep them send-once rather than inheriting the + // unrelated Telegram setting. "feishu" => ( true, true, @@ -271,7 +297,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { cfg!(feature = "teams"), StreamingMode::Disabled, teams_reactions, - if self.teams_processing_indicator && self.gw_state.teams.is_some() { + if self.teams_processing_indicator && teams_available { StatusBackend::Message } else if teams_reactions { StatusBackend::Reactions @@ -294,7 +320,7 @@ impl ChatAdapter for UnifiedGatewayAdapter { StatusBackend::Reactions, ), }; - AdapterCapabilities { + let mut capabilities = AdapterCapabilities { send_ack: cfg!(feature = "teams") && platform == "teams", edit_ack: cfg!(feature = "teams") && platform == "teams", delete_ack: cfg!(feature = "teams") && platform == "teams", @@ -312,7 +338,15 @@ impl ChatAdapter for UnifiedGatewayAdapter { _ => MessageLimit::Characters { max: 4096 }, }, status_backend, + }; + if platform == "teams" { + apply_teams_progressive_capabilities( + teams_available, + self.teams_streaming, + &mut capabilities, + ); } + capabilities } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { @@ -438,6 +472,14 @@ mod tests { assert!(capabilities.delete_ack); assert!(capabilities.supports_target_message_id); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); + assert_eq!( + adapter + .with_teams_streaming(true) + .capabilities("teams") + .streaming_mode, + StreamingMode::Disabled, + "an opt-in without an embedded Teams adapter must fail closed" + ); assert!(capabilities.can_edit); assert!(capabilities.can_delete); assert!(!capabilities.supports_reactions); @@ -467,13 +509,14 @@ mod tests { StatusBackend::Reactions ); - let message_adapter = adapter.with_teams_processing_indicator(true); + let message_adapter = adapter + .with_teams_processing_indicator(true) + .with_teams_streaming(true); let message_capabilities = message_adapter.capabilities("teams"); assert!(message_capabilities.supports_reactions); - assert_eq!( - message_capabilities.status_backend, - StatusBackend::Message - ); + assert_eq!(message_capabilities.status_backend, StatusBackend::Message); + assert_eq!(message_capabilities.streaming_mode, StreamingMode::Edit); + assert!(message_capabilities.show_streaming_placeholder); } #[cfg(feature = "teams")] @@ -500,7 +543,7 @@ mod tests { )?, None ); - assert!(UnifiedGatewayAdapter::teams_outcome_result( + let rejected = UnifiedGatewayAdapter::teams_outcome_result( WriteOutcome::Rejected { code: "route_not_found".into(), message: "missing".into(), @@ -508,15 +551,24 @@ mod tests { }, true, ) - .is_err()); - assert!(UnifiedGatewayAdapter::teams_outcome_result( + .unwrap_err(); + assert!(matches!( + &rejected.downcast_ref::().unwrap().outcome, + CoreWriteOutcome::Rejected { code, .. } if code == "route_not_found" + )); + + let unknown = UnifiedGatewayAdapter::teams_outcome_result( WriteOutcome::Unknown { code: "request_timeout".into(), message: "ambiguous".into(), }, true, ) - .is_err()); + .unwrap_err(); + assert!(matches!( + &unknown.downcast_ref::().unwrap().outcome, + CoreWriteOutcome::Unknown { code, .. } if code == "request_timeout" + )); Ok(()) } From 68926c136780158de64dbeb262cc22941abd1965 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Mon, 10 Aug 2026 00:56:48 +0900 Subject: [PATCH 14/16] feat(teams): add metadata-first attachment ingress --- charts/openab/README.md | 3 + charts/openab/templates/deployment.yaml | 4 + charts/openab/templates/gateway.yaml | 4 + charts/openab/values.yaml | 3 + config.toml.example | 1 + crates/openab-core/src/adapter.rs | 25 + crates/openab-core/src/config.rs | 29 + crates/openab-core/src/gateway.rs | 564 +++++++- .../openab-gateway/src/adapters/acp_server.rs | 1 + crates/openab-gateway/src/adapters/feishu.rs | 11 + .../openab-gateway/src/adapters/googlechat.rs | 11 + crates/openab-gateway/src/adapters/line.rs | 9 + .../openab-gateway/src/adapters/lineworks.rs | 4 + crates/openab-gateway/src/adapters/teams.rs | 1287 ++++++++++++++++- .../src/adapters/teams_ingress.rs | 169 ++- .../openab-gateway/src/adapters/telegram.rs | 5 + crates/openab-gateway/src/adapters/wecom.rs | 5 + crates/openab-gateway/src/lib.rs | 235 ++- crates/openab-gateway/src/schema.rs | 114 ++ .../tests/config_first_conformance.rs | 1 + docs/config-reference.md | 3 + docs/inbound-attachments.md | 26 +- docs/msteams-enterprise.md | 23 +- docs/msteams-selfhosted.md | 17 + docs/platforms/schema/teams.toml | 11 +- src/main.rs | 19 +- src/unified_adapter.rs | 103 +- 27 files changed, 2652 insertions(+), 35 deletions(-) diff --git a/charts/openab/README.md b/charts/openab/README.md index ca5221afb..518a924eb 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -49,6 +49,7 @@ Each agent lives under `agents.`. | `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` | | `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` | | `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` | | `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | | `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | | `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | @@ -125,6 +126,8 @@ 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. + ### Discord ID precision warning Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision. 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 e7b7fcd48..9f3e093b2 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -112,6 +112,10 @@ spec: - 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 }} {{- end }} {{- $hasFeishu := and (($cfg.gateway).feishu).appId (($cfg.gateway).feishu).appSecret }} {{- if $hasFeishu }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 632bafde4..7e6e422dd 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -468,6 +468,9 @@ agents: 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 # 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 924bc1665..a073dd9ea 100644 --- a/config.toml.example +++ b/config.toml.example @@ -140,6 +140,7 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # 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 # 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 diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index 19d8e805a..a1507a087 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -386,6 +386,9 @@ pub struct AdapterCapabilities { /// 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -402,6 +405,7 @@ impl Default for AdapterCapabilities { delete_ack: false, supports_target_message_id: false, supports_reactions: false, + supports_attachment_materialization: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, @@ -479,6 +483,17 @@ pub enum WriteOutcomeKind { 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] @@ -526,6 +541,16 @@ pub trait ChatAdapter: Send + Sync + 'static { } } + /// 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")) + } + /// Send a new message, returns a reference to the sent message. async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result; diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index cf1bfb798..f70753b28 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1352,6 +1352,9 @@ pub struct TeamsConfig { /// 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, /// Team IDs admitted by typed channel scope. Env fallback: /// `TEAMS_ALLOWED_TEAMS` (comma-separated). Both scope lists empty = open. pub allowed_teams: Option>, @@ -1385,6 +1388,7 @@ pub struct ResolvedTeams { pub reactions_enabled: bool, pub processing_indicator: TeamsProcessingIndicator, pub streaming: bool, + pub inbound_attachments: bool, pub allowed_teams: Vec, pub allowed_channels: Vec, pub allow_personal: bool, @@ -1503,6 +1507,11 @@ impl TeamsConfig { }), processing_indicator, streaming: bool_with_default(self.streaming, "TEAMS_STREAMING", false), + inbound_attachments: bool_with_default( + self.inbound_attachments, + "TEAMS_INBOUND_ATTACHMENTS", + false, + ), 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), @@ -3200,6 +3209,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_REACTIONS_ENABLED", "TEAMS_PROCESSING_INDICATOR", "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3220,6 +3230,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] assert!(!r.reactions_enabled); assert_eq!(r.processing_indicator, TeamsProcessingIndicator::Off); assert!(!r.streaming); + assert!(!r.inbound_attachments); assert!(r.allowed_teams.is_empty()); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3243,6 +3254,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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_ALLOWED_TEAMS", "env-team"); std::env::set_var("TEAMS_ALLOWED_CHANNELS", "env-channel"); std::env::set_var("TEAMS_ALLOW_PERSONAL", "false"); @@ -3257,6 +3269,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] reactions_enabled: Some(true), processing_indicator: Some(TeamsProcessingIndicator::Message), streaming: Some(true), + inbound_attachments: Some(true), allowed_teams: Some(vec!["cfg-team".into()]), allowed_channels: Some(vec![]), allow_personal: Some(true), @@ -3276,6 +3289,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] TeamsProcessingIndicator::Message ); assert!(r.streaming); + assert!(r.inbound_attachments); assert_eq!(r.allowed_teams, vec!["cfg-team"]); assert!(r.allowed_channels.is_empty()); assert!(r.allow_personal); @@ -3286,6 +3300,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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() @@ -3302,6 +3317,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] TeamsProcessingIndicator::Message ); assert!(r.streaming); + assert!(r.inbound_attachments); assert_eq!(r.allowed_teams, vec!["env-team"]); assert_eq!(r.allowed_channels, vec!["env-channel"]); assert!(!r.allow_personal); @@ -3310,18 +3326,24 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] // --- 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 --- @@ -3346,6 +3368,7 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "TEAMS_REACTIONS_ENABLED", "TEAMS_PROCESSING_INDICATOR", "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", @@ -3371,6 +3394,12 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] 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"] { diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index b6c823ea7..6c009b8c0 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1,19 +1,24 @@ use crate::acp::ContentBlock; use crate::adapter::{ - AdapterCapabilities, AdapterRouter, ChannelRef, ChatAdapter, MessageLimit, MessageRef, - SenderContext, StatusBackend, StreamingMode, WriteFailure, WriteOutcome, WriteOutcomeKind, + AdapterCapabilities, AdapterRouter, ChannelRef, ChatAdapter, MaterializedAttachment, + MessageLimit, MessageRef, SenderContext, StatusBackend, StreamingMode, WriteFailure, + WriteOutcome, WriteOutcomeKind, }; 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::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::Arc; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tracing::{error, info, warn}; 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; fn write_failure(outcome: WriteOutcome) -> anyhow::Error { WriteFailure::new(outcome).into() @@ -61,6 +66,7 @@ fn legacy_gateway_capabilities( delete_ack: false, supports_target_message_id: false, supports_reactions: true, + supports_attachment_materialization: false, can_edit, can_delete: platform == "feishu", streaming_mode: if streaming && can_edit { @@ -292,6 +298,8 @@ struct GwAttachment { filename: String, mime_type: String, #[serde(default)] + reference: Option, + #[serde(default)] data: String, #[allow(dead_code)] size: u64, @@ -468,6 +476,8 @@ struct GatewayReply { /// 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, } #[derive(Serialize)] @@ -499,6 +509,8 @@ struct GatewayResponse { error_code: Option, #[serde(default)] retry_after_ms: Option, + #[serde(default)] + attachment: Option, } impl GatewayResponse { @@ -594,6 +606,14 @@ impl GatewayCapabilityState { .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 @@ -616,6 +636,25 @@ impl GatewayCapabilityState { // --- 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 PendingAttachmentRequest { + pending: PendingRequests, + request_id: String, +} + +impl Drop for PendingAttachmentRequest { + 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< @@ -634,12 +673,14 @@ struct GatewayAdapterOptions { 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, @@ -648,6 +689,7 @@ pub struct GatewayAdapter { telegram_rich_messages: bool, teams_processing_indicator: bool, teams_streaming: bool, + teams_inbound_attachments: bool, ack_timeout: std::time::Duration, } @@ -655,6 +697,7 @@ impl GatewayAdapter { fn new( ws_tx: SharedWsTx, pending: PendingRequests, + connection_active: Arc, capability_state: Arc, options: GatewayAdapterOptions, ) -> Self { @@ -665,11 +708,13 @@ impl GatewayAdapter { 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, @@ -682,6 +727,7 @@ impl GatewayAdapter { telegram_rich_messages, teams_processing_indicator, teams_streaming, + teams_inbound_attachments, ack_timeout: std::time::Duration::from_secs(gateway_ack_timeout_secs.max(1)), } } @@ -692,6 +738,9 @@ impl GatewayAdapter { .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(); // Teams has an independent default-off opt-in. Do not inherit the // generic Gateway streaming or placeholder switches. apply_teams_progressive_capabilities( @@ -744,6 +793,7 @@ impl GatewayAdapter { 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(), @@ -844,6 +894,107 @@ impl GatewayAdapter { message_id: msg_id, }) } + + 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 = PendingAttachmentRequest { + 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()), + }; + 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, + }) + } } /// Send a fire-and-forget reply via the shared WebSocket (no request-response). @@ -854,6 +1005,7 @@ async fn send_fire_and_forget( content: &str, ) -> Result<()> { 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(), @@ -1015,6 +1167,34 @@ impl ChatAdapter for GatewayAdapter { 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 send_message(&self, channel: &ChannelRef, content: &str) -> Result { self.send_gateway_reply(channel, content, None).await } @@ -1040,6 +1220,7 @@ impl ChatAdapter for GatewayAdapter { 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(), @@ -1085,6 +1266,7 @@ impl ChatAdapter for GatewayAdapter { self.resolved_capabilities_with_mode(&msg.channel.platform); 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(), @@ -1111,6 +1293,7 @@ impl ChatAdapter for GatewayAdapter { self.resolved_capabilities_with_mode(&msg.channel.platform); 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(), @@ -1167,6 +1350,7 @@ impl ChatAdapter for GatewayAdapter { }; 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(), @@ -1266,6 +1450,7 @@ impl ChatAdapter for GatewayAdapter { }; 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(), @@ -1368,6 +1553,7 @@ pub struct GatewayParams { 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, @@ -1395,6 +1581,7 @@ pub async fn run_gateway_adapter( 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; @@ -1421,7 +1608,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"); @@ -1441,6 +1639,7 @@ 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)?; @@ -1450,6 +1649,7 @@ pub async fn run_gateway_adapter( let adapter: Arc = Arc::new(GatewayAdapter::new( ws_tx.clone(), pending.clone(), + connection_active.clone(), capability_state.clone(), GatewayAdapterOptions { platform_name: platform, @@ -1458,6 +1658,7 @@ pub async fn run_gateway_adapter( telegram_rich_messages, teams_processing_indicator, teams_streaming, + teams_inbound_attachments, gateway_ack_timeout_secs, }, )); @@ -1479,6 +1680,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! { @@ -1534,6 +1749,22 @@ pub async fn run_gateway_adapter( match serde_json::from_str::(text_str) { Ok(event) => { + if event.platform.eq_ignore_ascii_case("teams") + && !event.content.attachments.is_empty() + { + let event_json = text_str.to_owned(); + let event_context = teams_event_context.clone(); + let event_order = teams_event_order.clone(); + tasks.spawn(async move { + let _guard = event_order.lock().await; + if let Err(error) = + process_gateway_event(&event_json, &event_context).await + { + warn!(error = %error, "teams attachment event processing failed"); + } + }); + continue; + } if should_skip_event(&event, &filter) { continue; } @@ -1869,6 +2100,8 @@ pub async fn run_gateway_adapter( } _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { + 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(()); @@ -1877,6 +2110,12 @@ pub async fn run_gateway_adapter( } } // inner loop — break here means reconnect + // Stop new attachment commands and wake any request waiting on a + // response that cannot arrive on this connection. Queued event tasks + // then fail materialization immediately and can still dispatch text. + connection_active.store(false, AtomicOrdering::Release); + pending.lock().await.clear(); + // Drain in-flight tasks before reconnecting while tasks.join_next().await.is_some() {} @@ -1893,6 +2132,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, @@ -1902,6 +2142,7 @@ pub struct GatewayEventContext { 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>, } @@ -2118,9 +2359,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 { @@ -2132,7 +2447,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; @@ -2353,7 +2670,228 @@ fn format_size(n: u64) -> String { #[cfg(test)] mod tests { use super::*; - use std::collections::HashSet; + use async_trait::async_trait; + use std::collections::{HashMap, HashSet}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct AttachmentProbeAdapter { + materializations: AtomicUsize, + sends: AtomicUsize, + } + + #[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", + ..AdapterCapabilities::default() + } + } + + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + + async fn materialize_attachment( + &self, + _channel: &ChannelRef, + _reference: &str, + ) -> Result { + self.materializations.fetch_add(1, Ordering::SeqCst); + 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); + 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(()) + } + } + + 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() + } + + #[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 { + materializations: AtomicUsize::new(0), + sends: AtomicUsize::new(0), + }); + 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.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 { + materializations: AtomicUsize::new(0), + sends: AtomicUsize::new(0), + }); + 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.materializations.load(Ordering::SeqCst), 1); + assert_eq!(probe.sends.load(Ordering::SeqCst), 0); + + 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() { @@ -3197,7 +3735,17 @@ mod tests { "is_bot": false }, "channel": { "id": "conversation-1", "type": "channel" }, - "content": { "type": "text", "text": "OpenAB hello" }, + "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": { diff --git a/crates/openab-gateway/src/adapters/acp_server.rs b/crates/openab-gateway/src/adapters/acp_server.rs index 2e1a36913..9f9154f50 100644 --- a/crates/openab-gateway/src/adapters/acp_server.rs +++ b/crates/openab-gateway/src/adapters/acp_server.rs @@ -3910,6 +3910,7 @@ mod acp_review_fixes { request_id: None, quote_message_id: None, target_message_id: None, + attachment_ref: None, } } diff --git a/crates/openab-gateway/src/adapters/feishu.rs b/crates/openab-gateway/src/adapters/feishu.rs index c76a6acf9..8ac7b1f57 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), @@ -4270,6 +4273,7 @@ mod tests { request_id: None, quote_message_id: Some("om_specific".into()), target_message_id: None, + attachment_ref: None, }; // quote_message_id should take priority let reply_target = reply.quote_message_id.as_deref() @@ -4297,6 +4301,7 @@ mod tests { request_id: None, quote_message_id: None, target_message_id: None, + attachment_ref: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4323,6 +4328,7 @@ mod tests { request_id: None, quote_message_id: None, target_message_id: None, + attachment_ref: None, }; let reply_target = reply.quote_message_id.as_deref() .or(reply.channel.thread_id.as_deref()); @@ -4390,6 +4396,7 @@ mod tests { request_id: None, quote_message_id: Some("om_invalid".into()), target_message_id: None, + attachment_ref: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4667,6 +4674,7 @@ mod tests { request_id: Some("req_seam_1".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4702,6 +4710,7 @@ mod tests { request_id: None, quote_message_id: None, target_message_id: None, + attachment_ref: None, }; handle_reply(&reply, &adapter, &event_tx).await; @@ -4762,6 +4771,7 @@ mod tests { request_id: request_id.map(|s| s.into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, } } @@ -4984,6 +4994,7 @@ mod tests { request_id: Some("r1".into()), quote_message_id: None, target_message_id: None, + attachment_ref: 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 7c3508558..e2da5ac04 100644 --- a/crates/openab-gateway/src/adapters/googlechat.rs +++ b/crates/openab-gateway/src/adapters/googlechat.rs @@ -1385,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), @@ -1493,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), @@ -1589,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), @@ -2070,6 +2073,7 @@ mod tests { request_id: Some("req_123".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2116,6 +2120,7 @@ mod tests { request_id: Some("req_fail".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2167,6 +2172,7 @@ mod tests { request_id: Some("req_empty".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2213,6 +2219,7 @@ mod tests { request_id: Some("req_multi_fail".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2249,6 +2256,7 @@ mod tests { request_id: Some("req_notoken".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2296,6 +2304,7 @@ mod tests { request_id: None, quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2340,6 +2349,7 @@ mod tests { request_id: Some("req_multi".into()), quote_message_id: None, target_message_id: None, + attachment_ref: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -2399,6 +2409,7 @@ mod tests { request_id: Some("req_partial".into()), quote_message_id: None, target_message_id: None, + attachment_ref: 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 eb22b076f..6fc023d18 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,7 @@ mod tests { let cache: crate::ReplyTokenCache = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); let reply = GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), reply_to: "evt1".into(), platform: "line".into(), diff --git a/crates/openab-gateway/src/adapters/lineworks.rs b/crates/openab-gateway/src/adapters/lineworks.rs index 54f56f220..64945a842 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,7 @@ mod tests { fn text_reply(channel_id: &str, text: &str, command: Option<&str>) -> GatewayReply { GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), reply_to: "evt_1".into(), platform: "lineworks".into(), diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index 6e3861a08..a77ccea00 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -1,14 +1,17 @@ use super::teams_ingress::{ - wait_for_publish, OwnershipLookupError, PublishReservation, PublishState, ReactionLookupError, - RouteLookupError, TeamsIngressCleanupStats, TeamsIngressRegistry, TeamsIngressRoute, + 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 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::borrow::Cow; +use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -36,6 +39,8 @@ pub struct Activity { pub reply_to_id: Option, #[serde(default)] pub entities: Vec, + #[serde(default)] + pub attachments: Vec, } #[allow(dead_code)] @@ -56,6 +61,16 @@ pub struct ActivityEntity { 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(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -280,6 +295,7 @@ pub struct TeamsConfig { pub route_ttl_secs: u64, pub max_route_entries: usize, pub reactions_enabled: bool, + pub inbound_attachments: bool, } impl TeamsConfig { @@ -327,6 +343,10 @@ impl TeamsConfig { read("TEAMS_REACTIONS_ENABLED"), "TEAMS_REACTIONS_ENABLED", ), + inbound_attachments: parse_opt_in_bool( + read("TEAMS_INBOUND_ATTACHMENTS"), + "TEAMS_INBOUND_ATTACHMENTS", + ), }) } } @@ -381,6 +401,7 @@ fn parse_positive_usize(raw: Option, key: &str, default: usize) -> usize pub struct TeamsAdapter { config: TeamsConfig, client: reqwest::Client, + attachment_client: reqwest::Client, token_cache: RwLock>, token_refresh_lock: Mutex<()>, openid_cache: RwLock>, @@ -399,6 +420,11 @@ 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"; @@ -413,13 +439,19 @@ enum ConnectorWriteBody<'a> { impl TeamsAdapter { pub fn new(config: TeamsConfig) -> Self { - Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), false) + 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"); @@ -432,6 +464,7 @@ impl TeamsAdapter { Self { config, client, + attachment_client: build_attachment_http_client(attachment_timeout), token_cache: RwLock::new(None), token_refresh_lock: Mutex::new(()), openid_cache: RwLock::new(None), @@ -446,12 +479,22 @@ impl TeamsAdapter { #[cfg(test)] pub(crate) fn new_for_test(config: TeamsConfig) -> Self { - Self::with_client(config, build_http_client(TEAMS_REQUEST_TIMEOUT), true) + 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) + Self::with_client( + config, + build_http_client(request_timeout), + true, + request_timeout, + ) } #[cfg(test)] @@ -482,6 +525,63 @@ impl TeamsAdapter { 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(), + 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; @@ -501,6 +601,10 @@ impl TeamsAdapter { self.config.reactions_enabled } + pub fn inbound_attachments_enabled(&self) -> bool { + self.config.inbound_attachments + } + fn conversation_write_shard(route: &TeamsIngressRoute) -> usize { let mut hasher = std::collections::hash_map::DefaultHasher::new(); route.tenant_id.hash(&mut hasher); @@ -1206,6 +1310,15 @@ fn build_http_client(request_timeout: Duration) -> reqwest::Client { .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, @@ -1612,6 +1725,668 @@ fn truncate_utf8(value: &mut String, max_bytes: usize) { 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 @@ -1714,10 +2489,11 @@ async fn accept_message_activity(state: Arc, activity: Activity return StatusCode::BAD_REQUEST; } - let text = match activity.text.as_deref() { - Some(text) if !text.trim().is_empty() => text.trim(), - _ => return StatusCode::OK, - }; + 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()) @@ -1777,6 +2553,14 @@ async fn accept_message_activity(state: Arc, activity: Activity .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() @@ -1809,6 +2593,7 @@ async fn accept_message_activity(state: Arc, activity: Activity 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(), @@ -1828,6 +2613,8 @@ async fn accept_message_activity(state: Arc, activity: Activity 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) { @@ -2337,6 +3124,7 @@ mod tests { route_ttl_secs: DEFAULT_ROUTE_TTL_SECS, max_route_entries: DEFAULT_MAX_ROUTE_ENTRIES, reactions_enabled: false, + inbound_attachments: false, } } @@ -2370,6 +3158,7 @@ mod tests { fn make_reply(command: Option<&str>) -> GatewayReply { GatewayReply { + attachment_ref: None, schema: "openab.gateway.reply.v1".into(), reply_to: "evt-1".into(), platform: "teams".into(), @@ -2425,9 +3214,24 @@ mod tests { channel_data: None, reply_to_id: 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(), @@ -2466,7 +3270,467 @@ mod tests { }), reply_to_id: Some("root-activity".into()), entities: vec![], + attachments: vec![], + } + } + + 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 + } + + 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, + } + } + + 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, + })), + } + } + + 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() + } + + #[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) + )); + + 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()); + + 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(()) + } + + #[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; + + 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); + + 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(()) + } + + #[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 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 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:"))); + + 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(()) + } + + #[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; + + 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(()) + } + + #[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}"); + + 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(()) + } + + #[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 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:"))); + + 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(()) } // --- webhook body limit --- @@ -3946,22 +5210,27 @@ mod tests { assert_eq!(config.route_ttl_secs, 84); assert_eq!(config.max_route_entries, 123); 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_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!(!config.reactions_enabled); + assert!(!config.inbound_attachments); Ok(()) } diff --git a/crates/openab-gateway/src/adapters/teams_ingress.rs b/crates/openab-gateway/src/adapters/teams_ingress.rs index 1753b07c5..9c395c73e 100644 --- a/crates/openab-gateway/src/adapters/teams_ingress.rs +++ b/crates/openab-gateway/src/adapters/teams_ingress.rs @@ -7,6 +7,7 @@ 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); @@ -44,6 +45,37 @@ impl TeamsRouteKey { } } +#[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. @@ -62,6 +94,8 @@ pub(super) struct TeamsIngressRoute { 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, } @@ -316,6 +350,59 @@ impl TeamsIngressRegistry { 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, @@ -410,10 +497,16 @@ impl TeamsIngressRegistry { ); } } + // 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: route.clone(), + route: owned_route, created_at: now, }, ); @@ -555,6 +648,18 @@ mod tests { 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, @@ -569,6 +674,8 @@ mod tests { 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, @@ -883,7 +990,10 @@ mod tests { let mut registry = TeamsIngressRegistry::new(Duration::from_secs(60), Duration::from_secs(10), 2); let route_key = key(1); - let owned_route = route(route_key.clone(), "event-1", base)?; + 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 @@ -893,6 +1003,10 @@ mod tests { 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( @@ -993,6 +1107,57 @@ mod tests { 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(); diff --git a/crates/openab-gateway/src/adapters/telegram.rs b/crates/openab-gateway/src/adapters/telegram.rs index 055b1f5c5..ebd2dab8d 100644 --- a/crates/openab-gateway/src/adapters/telegram.rs +++ b/crates/openab-gateway/src/adapters/telegram.rs @@ -438,6 +438,7 @@ pub async fn handle_reply( outcome: Some(crate::schema::WriteOutcomeKind::Delivered), error_code: None, retry_after_ms: None, + attachment: None, } } else { let err = body["description"] @@ -455,6 +456,7 @@ pub async fn handle_reply( outcome: Some(crate::schema::WriteOutcomeKind::Rejected), error_code: Some("platform_rejected".into()), retry_after_ms: None, + attachment: None, } } } @@ -468,6 +470,7 @@ pub async fn handle_reply( 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(); @@ -800,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), @@ -928,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 921516539..3e6139641 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -469,6 +469,7 @@ impl WecomAdapter { 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); @@ -508,6 +509,7 @@ impl WecomAdapter { 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); @@ -545,6 +547,7 @@ impl WecomAdapter { 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); @@ -1219,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), @@ -1409,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 6450d0aec..e8d3a21e4 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -328,6 +328,7 @@ impl AppState { show_streaming_placeholder: true, message_limit: characters(4096), supports_reactions: teams.reactions_enabled(), + supports_attachment_materialization: teams.inbound_attachments_enabled(), status_backend: if teams.reactions_enabled() { StatusBackend::Reactions } else { @@ -606,6 +607,7 @@ impl AppState { 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(); self.teams = adapters::teams::TeamsConfig::from_reader(|k| match k { "TEAMS_APP_ID" => cfg.app_id.clone(), "TEAMS_APP_SECRET" => cfg.app_secret.clone(), @@ -616,6 +618,7 @@ impl AppState { "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()), _ => None, }) .map(adapters::teams::TeamsAdapter::new); @@ -713,6 +716,7 @@ pub struct GatewayTeamsConfig { pub route_ttl_secs: u64, pub max_route_entries: usize, pub reactions_enabled: bool, + pub inbound_attachments: bool, } /// Start the shared Teams state sweeper for Standalone or Unified mode. @@ -1099,6 +1103,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>, @@ -1114,7 +1120,9 @@ 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 { @@ -1238,6 +1246,8 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: Arc::new(Mutex::new(HashMap::new())); let mut recv_task = tokio::spawn(async move { let client = reqwest::Client::new(); + #[cfg(feature = "teams")] + let mut attachment_materialization_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) { @@ -1252,6 +1262,15 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: ); } 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 + }); + } if let Ok(json) = serde_json::to_string(&hello) { if control_tx.send(json).await.is_err() { break; @@ -1309,6 +1328,91 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: } #[cfg(feature = "teams")] "teams" => { + 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 { @@ -1544,6 +1648,7 @@ mod l1_audit_tests { route_ttl_secs: 3600, max_route_entries: 10_000, reactions_enabled: true, + inbound_attachments: true, }); assert!(s.teams.is_some()); assert_eq!(s.teams_webhook_path, "/hook/teams"); @@ -1555,6 +1660,7 @@ mod l1_audit_tests { assert!(teams.edit_ack); assert!(teams.delete_ack); assert!(teams.supports_target_message_id); + assert!(teams.supports_attachment_materialization); assert!(teams.can_edit); assert!(teams.can_delete); assert_eq!(teams.streaming_mode, super::schema::StreamingMode::Disabled); @@ -1576,6 +1682,7 @@ mod l1_audit_tests { route_ttl_secs: 3600, max_route_entries: 10_000, reactions_enabled: false, + inbound_attachments: false, }); assert!(s.teams.is_none()); } @@ -1714,6 +1821,7 @@ mod gateway_protocol_tests { route_ttl_secs: 3600, max_route_entries: 10_000, reactions_enabled: false, + inbound_attachments: false, } } @@ -1851,6 +1959,7 @@ mod gateway_protocol_tests { 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(), @@ -1887,6 +1996,7 @@ mod gateway_protocol_tests { 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(), @@ -1966,6 +2076,7 @@ mod gateway_protocol_tests { 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(), @@ -2013,6 +2124,7 @@ mod gateway_protocol_tests { route_ttl_secs: 3600, max_route_entries: 10_000, reactions_enabled: false, + inbound_attachments: true, }); let hello = build_gateway_hello( &state, @@ -2031,9 +2143,128 @@ mod gateway_protocol_tests { assert!(teams.edit_ack); assert!(teams.delete_ack); assert!(teams.supports_target_message_id); + assert!(teams.supports_attachment_materialization); assert!(!teams.supports_reactions); } + #[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!("ws://{addr}/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()), + }; + 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() { @@ -2057,6 +2288,7 @@ mod gateway_protocol_tests { 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(), @@ -2123,6 +2355,7 @@ mod gateway_protocol_tests { 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(), diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index 3b35dbfba..31dfdb692 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -1,3 +1,4 @@ +use base64::Engine; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -86,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")] @@ -116,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( @@ -129,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, @@ -195,6 +205,9 @@ pub struct AdapterCapabilities { /// 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, pub can_edit: bool, pub can_delete: bool, pub streaming_mode: StreamingMode, @@ -211,6 +224,7 @@ impl Default for AdapterCapabilities { delete_ack: false, supports_target_message_id: false, supports_reactions: false, + supports_attachment_materialization: false, can_edit: false, can_delete: false, streaming_mode: StreamingMode::Disabled, @@ -271,6 +285,9 @@ pub struct GatewayReply { /// 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)] @@ -323,6 +340,9 @@ pub struct GatewayResponse { 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 { @@ -339,6 +359,7 @@ impl GatewayResponse { outcome: Some(WriteOutcomeKind::Delivered), error_code: None, retry_after_ms: None, + attachment: None, }, WriteOutcome::Rejected { code, @@ -354,6 +375,7 @@ impl GatewayResponse { outcome: Some(WriteOutcomeKind::Rejected), error_code: Some(code), retry_after_ms, + attachment: None, }, WriteOutcome::Unknown { code, message } => Self { schema: "openab.gateway.response.v1".into(), @@ -365,10 +387,45 @@ impl GatewayResponse { 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 { @@ -541,6 +598,7 @@ mod protocol_tests { 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)?; @@ -558,10 +616,65 @@ mod protocol_tests { "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 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)] @@ -662,6 +775,7 @@ mod protocol_tests { assert!(!capabilities.delete_ack); assert!(!capabilities.supports_target_message_id); assert!(!capabilities.supports_reactions); + assert!(!capabilities.supports_attachment_materialization); assert!(!capabilities.can_edit); assert!(!capabilities.can_delete); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); diff --git a/crates/openab-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index 6ca994eb3..59821dbd1 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -105,6 +105,7 @@ const COVERED: &[&str] = &[ "TEAMS_WEBHOOK_PATH", "TEAMS_PROCESSING_INDICATOR", "TEAMS_STREAMING", + "TEAMS_INBOUND_ATTACHMENTS", "TEAMS_ALLOWED_TEAMS", "TEAMS_ALLOWED_CHANNELS", "TEAMS_ALLOW_PERSONAL", diff --git a/docs/config-reference.md b/docs/config-reference.md index 52bf91d18..bc0619af0 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -247,6 +247,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > > `streaming = true` opts into a separate progressive content placeholder. It is enabled only after Standalone hello (or the Unified Teams adapter) proves real-ID send plus bot-owned edit/delete with required ACKs. The generic `[gateway].streaming` and Telegram settings never enable Teams. Unknown write outcomes suppress recovery sends to avoid duplicates; no Graph/RSC grant is used. Microsoft 365 live validation is still pending. > +> `inbound_attachments = true` enables metadata-first Teams image/text ingress. Gateway publishes only bounded metadata and an opaque process-local reference; Core requests bytes only after structural, typed L2, and L3 identity admission. URLs, query strings, and Microsoft credentials stay in Gateway. Inline images work in all scopes; Personal `file.download.info` image and UTF-8 text files additionally require a separate manifest profile with `supportsFiles: true`. Group-chat/channel paperclip files remain unsupported without Graph. Standalone requires the opt-in on both processes plus a valid Gateway hello advertising the additive materialization capability; malformed switches, old peers, route expiry, restart, scope mismatch, and oversized data fail closed without retry. +> > Teams Personal, group-chat, and channel scope is derived from the authenticated Bot Framework activity. Presence of any of `allowed_teams`, `allowed_channels`, `allow_personal`, or `allow_group_chats` (or its environment variable) opts into typed L2 policy. With neither list populated, all Team channels are admitted; otherwise a Team **or** channel ID match admits the channel. Personal and group chats use their booleans. L3 user trust is still evaluated independently. The two boolean environment variables accept `true`/`false` or `1`/`0`; any other explicitly present value resolves to `false` (fail closed). > > If none of the typed fields is present, Core preserves the pre-PR-5 `[gateway].allowed_channels` / `GATEWAY_ALLOWED_CHANNELS` conversation-ID behavior for rolling upgrades. This fallback is logged. `ChannelInfo.id` remains the outbound conversation ID; typed scope never changes routing or session keys. @@ -265,6 +267,7 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con | `reactions_enabled` | bool | `false` | Enable public-preview add/remove reactions and advertise reaction availability. Env: `TEAMS_REACTIONS_ENABLED`. | | `processing_indicator` | `off` \| `message` | `off` | Opt in to one processing message per admitted turn. Requires negotiated send/edit/delete ACK and real target support; malformed env values fail closed to `off`. Env: `TEAMS_PROCESSING_INDICATOR`. | | `streaming` | bool | `false` | Opt in to progressive bot-owned content edits. Requires valid hello plus send/edit/delete ACK, real target IDs, and placeholder support; malformed env values fail closed to `false`. Env: `TEAMS_STREAMING`. | +| `inbound_attachments` | bool | `false` | Opt in to post-trust opaque-reference materialization for bounded inline images and Personal image/UTF-8 text files. Requires a valid negotiated capability; malformed env values fail closed. Env: `TEAMS_INBOUND_ATTACHMENTS`. | | `allowed_teams` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Team IDs admitted for channel conversations. If either scope list is non-empty, Team **or** channel match admits. Env: `TEAMS_ALLOWED_TEAMS` (comma-separated). | | `allowed_channels` | string[] \| omit | `[]` (all Team channels when both lists are empty) | Teams channel IDs admitted for channel conversations. Env: `TEAMS_ALLOWED_CHANNELS` (comma-separated). | | `allow_personal` | bool \| omit | `true` | Admit Personal conversations under typed policy. Env: `TEAMS_ALLOW_PERSONAL`. | diff --git a/docs/inbound-attachments.md b/docs/inbound-attachments.md index 7b3f98d8d..43f090130 100644 --- a/docs/inbound-attachments.md +++ b/docs/inbound-attachments.md @@ -4,6 +4,8 @@ How OAB handles images, audio, and files sent by users across all platforms. ## Architecture +Most colocated adapters use the shared filesystem path: + ``` User sends media (photo/voice/file) → Platform webhook delivers to Gateway @@ -16,6 +18,13 @@ User sends media (photo/voice/file) → File auto-evicted after 2 minutes ``` +Teams deliberately uses a different two-phase path in Unified and Standalone +modes. Gateway first publishes sanitized metadata plus an opaque process-local +reference. Core runs structural, L2, and L3 trust gates, then requests bounded +materialization. Gateway downloads and returns normalized base64 bytes; the +Microsoft URL, query, and credential never leave Gateway. See +[Teams metadata-first attachment ingress](adr/teams-attachment-ingress.md). + ## Platform Support Matrix | Platform | Images | Audio/Voice | Text Files | Video | Binary Files | @@ -51,6 +60,7 @@ OpenAB can create the ACP image block, but downstream coding agents and selected 4. If STT disabled: silently skipped LINE-specific note: + - LINE voice-message STT currently works in **1:1 chats only**. - LINE group/room voice messages are still blocked by mention gating because LINE does not attach mention metadata to audio messages. @@ -73,6 +83,9 @@ Binary files (zip, pdf, exe, docx), video, and stickers are **rejected with a st | Images | 10 MB | Gateway (pre-download Content-Length + post-download bytes) | | Audio | 20 MB | Gateway | | Text files | 20 MB | Gateway (same as store cap) | +| Teams text files | 512 KiB | Teams materializer | +| Teams aggregate raw bytes | 20 MiB/event | Route-scoped materialization budget | +| Teams materialized WS frame | 8 MiB | Gateway and Core WebSocket limits | | GIF passthrough | 5 MB | `resize_and_compress()` | | Store (defense-in-depth) | 20 MB | `store_media()` | @@ -94,11 +107,20 @@ Media is stored at `~/.openab/media/inbound/`: ### Future: HTTP Proxy Mode -For separated deployments (Gateway ≠ Core pod), a future PR will add `GET /media/` on the Gateway, allowing Core to fetch via internal HTTP. The `attachments[].path` field will be replaced by `attachments[].url` in that mode. +Other separated deployments may eventually add an authenticated media proxy. +Teams does not wait for that work: its reviewed contract already uses one +bounded opaque-reference request and base64 response, never `Attachment.path` +or a Microsoft URL in Core. ## Configuration -No additional configuration required. The filesystem store is always active when Gateway is running. Ensure Gateway and Core share the same `$HOME` (default in Helm colocate/sidecar mode). +The filesystem store used by existing colocated adapters requires no additional +setting; Gateway and Core must share `$HOME`. + +Teams is explicitly default-off. Set `[teams].inbound_attachments = true` (or +`TEAMS_INBOUND_ATTACHMENTS=true`) on Core and Gateway. Personal paperclip files +also require a separate Teams manifest profile with `supportsFiles: true`; +inline images do not. ## Related diff --git a/docs/msteams-enterprise.md b/docs/msteams-enterprise.md index 929a8ee0f..d56891a61 100644 --- a/docs/msteams-enterprise.md +++ b/docs/msteams-enterprise.md @@ -122,6 +122,11 @@ Create a directory with three files: - `id` — Teams app ID (generate a fresh UUID v4, not the same as `botId`) - `botId` — Azure Entra ID Application (client) ID from Step 1 +- Keep this base profile at `supportsFiles: false`. Inline pasted images still + work when attachment ingress is enabled. To accept Personal-chat paperclip + files, create a separate reviewed manifest package with `supportsFiles: true`; + Microsoft file consent is Personal-only and does not enable group-chat or + channel files without Graph. ### Icons @@ -238,6 +243,7 @@ agents: # reactions_enabled = true # public-preview live-tenant test only # processing_indicator = "message" # default off; no Graph/RSC # streaming = true # default off; progressive bot-owned content edits + # inbound_attachments = true # default off; post-trust image/text materialization [agent] command = "kiro-cli" @@ -403,6 +409,8 @@ stringData: TEAMS_APP_SECRET: "" TEAMS_OAUTH_ENDPOINT: "https://login.microsoftonline.com//oauth2/v2.0/token" TEAMS_ALLOWED_TENANTS: "" + # Optional, default off. Core must enable the same switch below. + TEAMS_INBOUND_ATTACHMENTS: "true" ``` > **⚠️ Single Tenant bots must set `TEAMS_OAUTH_ENDPOINT`** to the tenant-specific endpoint. The default (`botframework.com`) only works for Multi Tenant bots and will cause `401 Unauthorized` errors. This is the #1 setup pitfall. @@ -521,6 +529,7 @@ agents: allow_group_chats = false processing_indicator = "off" # set "message" after Gateway capability validation streaming = false # enable only after Gateway capability validation + inbound_attachments = true # must match the Standalone Gateway env switch allowed_users = ["29:1abc..."] [agent] @@ -538,6 +547,8 @@ The chart mounts `configToml` verbatim. The sample uses first-class `[teams]` ty For a rolling deployment with an older Gateway, absent additive scope falls back to the legacy `[gateway].allowed_channels` conversation-ID policy. Do not put Team IDs into that legacy field: they are not Bot Connector conversation IDs. Upgrade the Gateway before relying exclusively on Team/channel typed allowlists. +Attachment ingress must be explicitly enabled on both processes. Core downloads nothing unless a valid Gateway hello advertises `supports_attachment_materialization`; Gateway stores only bounded metadata until Core has admitted structural, typed L2, and L3 trust. Do not pass Microsoft download URLs or bot credentials through Core configuration. + Install the chart version validated with Gateway 0.5.4: ```bash @@ -635,6 +646,7 @@ set transport variables on the Gateway through `openab-gateway-teams`. Typed sco | `TEAMS_REACTIONS_ENABLED` | No | `false` | Opt in to public-preview Bot Connector add/remove reactions; no Graph/RSC grant required | | `TEAMS_PROCESSING_INDICATOR` | No | `off` | Core processing UX: `off` or `message`; malformed values fail closed to `off` | | `TEAMS_STREAMING` | No | `false` | Core progressive-content opt-in; accepts only `true`/`false` or `1`/`0`, malformed values fail closed | +| `TEAMS_INBOUND_ATTACHMENTS` | No | `false` | Core/Gateway metadata-first image/text opt-in; set identically on both Standalone processes. Only `true`/`false` or `1`/`0`; malformed values fail closed. | | `TEAMS_ALLOWED_TEAMS` | No | (empty) | Core typed L2 Team-ID allowlist, comma-separated; Team OR channel match | | `TEAMS_ALLOWED_CHANNELS` | No | (empty) | Core typed L2 channel-ID allowlist, comma-separated; both lists empty means all Team channels | | `TEAMS_ALLOW_PERSONAL` | No | `true` | Core typed L2 Personal-chat switch | @@ -754,9 +766,14 @@ kubectl run openab-metadata-check --rm -i --restart=Never \ the minimum outbound destinations. The M0 public-cloud profile permits the `login.microsoftonline.com` token endpoint, `login.botframework.com` metadata and JWKS, and validated HTTPS Bot Connector service URLs on - `smba.trafficmanager.net`. Sovereign-cloud and custom proxy hosts are rejected. - The OAB/ACP pod also needs the authentication/API endpoints for the selected - agent backend and any model or tool services it uses. In Unified Mode these + `smba.trafficmanager.net`. Attachment-enabled Gateway pods additionally need + HTTPS egress to the compiled commercial Microsoft file-host profile: + `api.asm.skype.com`, `files.teams.microsoft.com`, and the corresponding root + or subdomains under `sharepoint.com`, `sharepointonline.com`, `1drv.com`, + `onedrive.com`, and `blob.core.windows.net`. Sovereign-cloud and custom proxy + hosts are rejected. The OAB/ACP pod does not fetch Microsoft attachments; it + still needs the authentication/API endpoints for the selected agent backend + and any model or tool services it uses. In Unified Mode these rules apply to the OAB pod. In Standalone Gateway Mode, give the Gateway the Microsoft egress, allow OAB to reach `openab-gateway:8080`, and give only OAB the agent/model/tool egress. diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index ee2359b18..6ecadd72d 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -194,6 +194,10 @@ Notes: - `id` is the **Teams app id** — generate a fresh UUID v4 (`uuidgen`). It is **not** the same as `botId`. - `botId` is the **Microsoft App (Bot) id** from step 1 (the value you put in `TEAMS_APP_ID`). - The three `developer.*` URLs are required by the schema. They can point at your GitHub repo / privacy page / license — they just have to resolve. +- Keep the base package at `supportsFiles: false`; inline pasted images do not + require file consent. For Personal paperclip image/text files, make a separate + package with `supportsFiles: true`. This does not enable group-chat or channel + paperclip files without Microsoft Graph. > If your tenant requires admin approval, an admin must approve the published app in Teams Admin Center → Manage apps. @@ -215,6 +219,10 @@ TEAMS_OAUTH_ENDPOINT="https://login.microsoftonline.com//oauth2/ # Optional Microsoft public-preview status reactions # TEAMS_REACTIONS_ENABLED=true +# Optional metadata-first image/text ingress on the Gateway. If enabled, also +# set inbound_attachments=true in Core config.toml below. +# TEAMS_INBOUND_ATTACHMENTS=true + # Only needed if you use the Cloudflare Tunnel service below. # Skip this line if you expose the gateway via a different reverse proxy. TUNNEL_TOKEN="" @@ -285,10 +293,19 @@ allowed_channels = [] # both empty = all Team channels allow_personal = true allow_group_chats = true allowed_users = ["29:1abc..."] +# Default off. Requires TEAMS_INBOUND_ATTACHMENTS=true on the Gateway and a +# valid negotiated materialization capability. +# inbound_attachments = true # Or replace allowed_users with the explicit broad opt-in: # allow_all_users = true ``` +When enabled, Gateway sends only sanitized attachment metadata and an opaque +process-local reference. Core asks for bytes only after structural, typed L2, +and L3 identity admission. Microsoft URLs, URL queries, and bot credentials stay +inside Gateway; old peers, route expiry, restart, scope mismatch, and malformed +or oversized data fail closed without automatic retry. + ### Start the stack ```bash diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 95e42e321..1ec17e3d4 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -203,9 +203,14 @@ pr = "" [[openab_features]] feature = "media_inbound" -status = "not_implemented" -note = "Webhook reads text plus structured recipient/mention entities, but attachments are neither parsed nor forwarded. The `Activity` DTO still does not model attachment payloads." -source = ["crates/openab-gateway/src/adapters/teams.rs#Activity"] +status = "partial" +note = "Default off. With `[teams].inbound_attachments = true` and an explicitly negotiated materialization capability, Gateway publishes at most ten sanitized metadata entries plus route-scoped opaque references. Core invokes sequential materialization only after structural, typed L2, and L3 Allow. Inline images work in all scopes; Personal file-download cards accept image extensions and strict UTF-8 text only. URLs/tokens remain Gateway-local, redirects and size budgets fail closed, and Standalone/Unified return the same bounded base64 envelope. Automated tests pass; Microsoft 365 live validation remains pending." +source = [ + "crates/openab-gateway/src/adapters/teams.rs#prepare_attachment_metadata", + "crates/openab-gateway/src/adapters/teams.rs#materialize_attachment", + "crates/openab-core/src/gateway.rs#process_gateway_event", + "docs/adr/teams-attachment-ingress.md", +] pr = "" [[openab_features]] diff --git a/src/main.rs b/src/main.rs index 7903a8121..6d7068178 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,6 +278,14 @@ fn teams_streaming_enabled(cfg: &config::Config) -> bool { cfg.teams.clone().unwrap_or_default().resolve().streaming } +fn teams_inbound_attachments_enabled(cfg: &config::Config) -> bool { + cfg.teams + .clone() + .unwrap_or_default() + .resolve() + .inbound_attachments +} + fn teams_scope_policy(cfg: &config::Config) -> gateway::TeamsScopePolicy { let legacy_allowed: Vec = std::env::var("GATEWAY_ALLOWED_CHANNELS") .unwrap_or_default() @@ -537,6 +545,7 @@ async fn main() -> anyhow::Result<()> { let teams_scope_policy = teams_scope_policy(&cfg); let teams_processing_indicator = teams_processing_indicator_enabled(&cfg); let teams_streaming = teams_streaming_enabled(&cfg); + let teams_inbound_attachments = teams_inbound_attachments_enabled(&cfg); let teams_routing_active = cfg .gateway .as_ref() @@ -1177,6 +1186,7 @@ async fn main() -> anyhow::Result<()> { telegram_rich_messages: gw_cfg.telegram_rich_messages, teams_processing_indicator, teams_streaming, + teams_inbound_attachments, gateway_ack_timeout_secs: gw_cfg.gateway_ack_timeout_secs, stt: cfg.stt.clone(), teams_scope_policy: teams_scope_policy.clone(), @@ -1379,6 +1389,7 @@ async fn main() -> anyhow::Result<()> { route_ttl_secs: r.route_ttl_secs, max_route_entries: r.max_route_entries, reactions_enabled: r.reactions_enabled, + inbound_attachments: r.inbound_attachments, }); } // First-class `[feishu]` config overrides env-derived values @@ -1564,7 +1575,8 @@ async fn main() -> anyhow::Result<()> { let unified_adapter: Arc = Arc::new( unified_adapter::UnifiedGatewayAdapter::new(gw_state.clone()) .with_teams_processing_indicator(teams_processing_indicator) - .with_teams_streaming(teams_streaming), + .with_teams_streaming(teams_streaming) + .with_teams_inbound_attachments(teams_inbound_attachments), ); // Bot gating still reads env here (structural, not L2/L3): @@ -1594,6 +1606,7 @@ async fn main() -> anyhow::Result<()> { bot_username: gw_bot_username, stt_config: cfg.stt.clone(), teams_scope_policy: teams_scope_policy.clone(), + teams_inbound_attachments, #[cfg(feature = "filestore")] filestore: filestore.clone(), }); @@ -2001,14 +2014,16 @@ mod tests { let default_cfg = config::parse_config_str("", "test").unwrap(); assert!(!teams_processing_indicator_enabled(&default_cfg)); assert!(!teams_streaming_enabled(&default_cfg)); + assert!(!teams_inbound_attachments_enabled(&default_cfg)); let enabled_cfg = config::parse_config_str( - "[teams]\nprocessing_indicator = \"message\"\nstreaming = true\n", + "[teams]\nprocessing_indicator = \"message\"\nstreaming = true\ninbound_attachments = true\n", "test", ) .unwrap(); assert!(teams_processing_indicator_enabled(&enabled_cfg)); assert!(teams_streaming_enabled(&enabled_cfg)); + assert!(teams_inbound_attachments_enabled(&enabled_cfg)); } #[test] diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index efb009036..c1547d9cb 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -4,8 +4,8 @@ use anyhow::Result; use async_trait::async_trait; use openab_core::adapter::{ - AdapterCapabilities, ChannelRef, ChatAdapter, MessageLimit, MessageRef, StatusBackend, - StreamingMode, + AdapterCapabilities, ChannelRef, ChatAdapter, MaterializedAttachment, MessageLimit, MessageRef, + StatusBackend, StreamingMode, }; #[cfg(feature = "teams")] use openab_core::adapter::{WriteFailure, WriteOutcome as CoreWriteOutcome}; @@ -27,6 +27,8 @@ pub struct UnifiedGatewayAdapter { teams_processing_indicator: bool, /// Core-side default-off Teams progressive-content policy. teams_streaming: bool, + /// Core-side default-off Teams inbound attachment policy. + teams_inbound_attachments: bool, } impl UnifiedGatewayAdapter { @@ -36,6 +38,7 @@ impl UnifiedGatewayAdapter { telegram_reaction_state: Arc::new(Mutex::new(HashMap::new())), teams_processing_indicator: false, teams_streaming: false, + teams_inbound_attachments: false, } } @@ -49,6 +52,11 @@ impl UnifiedGatewayAdapter { self } + pub fn with_teams_inbound_attachments(mut self, enabled: bool) -> Self { + self.teams_inbound_attachments = enabled; + self + } + /// Dispatch a GatewayReply to the correct platform adapter. async fn dispatch_reply(&self, reply: &GatewayReply) -> Result> { let client = &self.gw_state.client; @@ -213,6 +221,7 @@ impl UnifiedGatewayAdapter { request_id: None, quote_message_id: quote_message_id.map(|s| s.into()), target_message_id: None, + attachment_ref: None, } } @@ -255,6 +264,14 @@ impl ChatAdapter for UnifiedGatewayAdapter { .is_some_and(|teams| teams.reactions_enabled()); #[cfg(not(feature = "teams"))] let teams_reactions = false; + #[cfg(feature = "teams")] + let teams_materialization = self + .gw_state + .teams + .as_ref() + .is_some_and(|teams| teams.inbound_attachments_enabled()); + #[cfg(not(feature = "teams"))] + let teams_materialization = false; let (can_edit, can_delete, streaming_mode, supports_reactions, status_backend) = match platform { "telegram" => ( @@ -325,6 +342,10 @@ impl ChatAdapter for UnifiedGatewayAdapter { edit_ack: cfg!(feature = "teams") && platform == "teams", delete_ack: cfg!(feature = "teams") && platform == "teams", supports_target_message_id: cfg!(feature = "teams") && platform == "teams", + supports_attachment_materialization: platform == "teams" + && teams_available + && teams_materialization + && self.teams_inbound_attachments, can_edit, can_delete, streaming_mode, @@ -349,6 +370,72 @@ impl ChatAdapter for UnifiedGatewayAdapter { capabilities } + async fn materialize_attachment( + &self, + channel: &ChannelRef, + reference: &str, + ) -> Result { + if !self + .capabilities(&channel.platform) + .supports_attachment_materialization + { + anyhow::bail!("attachment materialization is unavailable"); + } + #[cfg(feature = "teams")] + { + let event_id = channel + .origin_event_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("attachment route is unavailable"))?; + let teams = self + .gw_state + .teams + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Teams adapter is not configured"))?; + let attachment = teams + .materialize_attachment(event_id, &channel.channel_id, reference) + .await?; + if attachment.path.is_some() || attachment.reference.is_some() { + anyhow::bail!("materialized Teams attachment returned an invalid 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!("materialized Teams attachment returned invalid metadata"); + } + let data = attachment + .decoded_data() + .map_err(|_| anyhow::anyhow!("materialized attachment data is malformed"))?; + if attachment.status.is_some() { + if !data.is_empty() { + anyhow::bail!("rejected Teams attachment returned payload data"); + } + } else if attachment.size != data.len() as u64 { + anyhow::bail!("materialized Teams attachment size does not match its payload"); + } + return Ok(MaterializedAttachment { + attachment_type: attachment.attachment_type, + filename: attachment.filename, + mime_type: attachment.mime_type, + data, + size: attachment.size, + status: attachment.status, + }); + } + #[cfg(not(feature = "teams"))] + { + let _ = (channel, reference); + anyhow::bail!("Teams attachment materialization is not compiled") + } + } + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { let reply = self.build_reply(channel, content, None, None); let message_id = self @@ -471,6 +558,7 @@ mod tests { assert!(capabilities.edit_ack); assert!(capabilities.delete_ack); assert!(capabilities.supports_target_message_id); + assert!(!capabilities.supports_attachment_materialization); assert_eq!(capabilities.streaming_mode, StreamingMode::Disabled); assert_eq!( adapter @@ -500,19 +588,24 @@ mod tests { route_ttl_secs: 3600, max_route_entries: 10_000, reactions_enabled: true, + inbound_attachments: true, }); - let adapter = UnifiedGatewayAdapter::new(Arc::new(state)); + let state = Arc::new(state); + let adapter = UnifiedGatewayAdapter::new(state.clone()); let reaction_capabilities = adapter.capabilities("teams"); assert!(reaction_capabilities.supports_reactions); + assert!(!reaction_capabilities.supports_attachment_materialization); assert_eq!( reaction_capabilities.status_backend, StatusBackend::Reactions ); - let message_adapter = adapter + let message_adapter = UnifiedGatewayAdapter::new(state) .with_teams_processing_indicator(true) - .with_teams_streaming(true); + .with_teams_streaming(true) + .with_teams_inbound_attachments(true); let message_capabilities = message_adapter.capabilities("teams"); + assert!(message_capabilities.supports_attachment_materialization); assert!(message_capabilities.supports_reactions); assert_eq!(message_capabilities.status_backend, StatusBackend::Message); assert_eq!(message_capabilities.streaming_mode, StreamingMode::Edit); From fb9ddf2a490177d8ac1d4a2adf92e0e010daa060 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 01:03:08 +0800 Subject: [PATCH 15/16] feat(teams): add budget-aware long messages --- crates/openab-core/src/adapter.rs | 161 +++++- crates/openab-core/src/format.rs | 537 +++++++++++++++----- crates/openab-core/src/gateway.rs | 51 ++ crates/openab-core/src/progressive.rs | 293 +++++++++-- crates/openab-gateway/src/adapters/teams.rs | 18 + crates/openab-gateway/src/lib.rs | 12 +- crates/openab-gateway/src/schema.rs | 21 + docs/config-reference.md | 2 + docs/msteams-selfhosted.md | 6 +- docs/platforms/schema/teams.toml | 18 +- scripts/teams-ack-drop-proxy.py | 24 +- scripts/test-teams-ack-drop-proxy.py | 68 ++- src/unified_adapter.rs | 16 +- 13 files changed, 1033 insertions(+), 194 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index a1507a087..6091f6996 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -10,10 +10,9 @@ use crate::error_display::{format_coded_error, format_user_error}; use crate::format; use crate::markdown::{self, TableMode}; use crate::progressive::{ - classify_placeholder, deliver_explicit_reply_chunks, deliver_fresh_chunks, - finalize_edit_after_cosmetic, finalize_explicit_reply, is_ambiguous_delivery, - AmbiguousProgressiveDelivery, CosmeticEditOutcome, CosmeticEditState, PlaceholderStart, - COSMETIC_EDIT_INTERVAL, + 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}; @@ -332,8 +331,8 @@ pub enum StreamingMode { Native, } -/// Platform message-size budget. The router converts non-character limits to a -/// conservative character bound until byte-aware splitting is implemented. +/// 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 { @@ -358,6 +357,15 @@ impl MessageLimit { 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. @@ -1008,6 +1016,7 @@ impl AdapterRouter { let adapter = adapter.clone(); let thread_channel = thread_channel.clone(); 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 @@ -1544,9 +1553,33 @@ 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 @@ -1554,6 +1587,7 @@ impl AdapterRouter { // silently calling set_done (🆗) over a half-delivered turn. 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. @@ -1634,6 +1668,9 @@ impl AdapterRouter { .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. @@ -1705,6 +1742,9 @@ impl AdapterRouter { .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 @@ -1754,22 +1794,37 @@ impl AdapterRouter { // 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 = if let Some(ref reply_id) = directives.reply_to { - deliver_explicit_reply_chunks( - &adapter, - &thread_channel, - reply_id, - &chunks, - ) - .await - } else { - deliver_fresh_chunks(&adapter, &thread_channel, &chunks).await - }; + 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 { @@ -1798,6 +1853,18 @@ 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 { if message_status_enabled { message_status @@ -1806,9 +1873,23 @@ impl AdapterRouter { } 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!( - "streaming finalization had delivery failures; user view is incomplete" + "finalization had delivery failures; user view is incomplete" )) } } else { @@ -2231,12 +2312,48 @@ mod tests { 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 { 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 6c009b8c0..7835e0c71 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -3053,6 +3053,57 @@ mod tests { 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 advertised = AdapterCapabilities { + send_ack: true, + message_limit: MessageLimit::Utf16Bytes { max: 80_000 }, + ..AdapterCapabilities::default() + }; + 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); + + 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 } + ); + } + #[test] fn legacy_and_structured_gateway_responses_map_to_write_outcomes() { let legacy: GatewayResponse = serde_json::from_value(serde_json::json!({ diff --git a/crates/openab-core/src/progressive.rs b/crates/openab-core/src/progressive.rs index 5f4e77bb1..69312e391 100644 --- a/crates/openab-core/src/progressive.rs +++ b/crates/openab-core/src/progressive.rs @@ -86,25 +86,69 @@ pub(crate) fn is_ambiguous_delivery(error: &anyhow::Error) -> bool { .is_some() } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[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() -> Self { + 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() -> Self { + 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 } } @@ -145,16 +189,46 @@ pub(crate) async fn deliver_fresh_chunks( channel: &ChannelRef, chunks: &[String], ) -> ProgressiveDelivery { - for chunk in chunks { + 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, .. } => { - tracing::warn!(error_code = %code, "progressive fresh chunk rejected"); - return ProgressiveDelivery::rejected(); + 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 { .. } | WriteOutcome::Unknown { .. } => { - tracing::warn!("progressive fresh chunk outcome unknown; stopping delivery"); - return ProgressiveDelivery::unknown(); + 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", + ); } } } @@ -169,11 +243,15 @@ async fn recover_rejected_placeholder( ) -> 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() + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, &code) } WriteOutcome::Delivered { .. } => deliver_fresh_chunks(adapter, channel, chunks).await, WriteOutcome::Rejected { code, .. } => { @@ -197,15 +275,19 @@ pub(crate) async fn finalize_edit_placeholder( }; match adapter.edit_message_outcome(placeholder, first).await { - WriteOutcome::Delivered { .. } => { - deliver_fresh_chunks(adapter, channel, &chunks[1..]).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() + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, &code) } WriteOutcome::Rejected { code, .. } => { tracing::warn!( @@ -235,9 +317,9 @@ pub(crate) async fn finalize_edit_after_cosmetic( FinalEditPlan::Put => { finalize_edit_placeholder(adapter, channel, placeholder, chunks).await } - FinalEditPlan::AlreadyDelivered => { - deliver_fresh_chunks(adapter, channel, &chunks[1..]).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" @@ -246,9 +328,13 @@ pub(crate) async fn finalize_edit_after_cosmetic( } FinalEditPlan::Ambiguous => { tracing::warn!( - "last cosmetic edit may already contain the final content; not retrying or recovering" + 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() + ProgressiveDelivery::unknown_chunk(0, chunks.len(), 0, "cosmetic_edit_unknown") } } } @@ -269,16 +355,55 @@ pub(crate) async fn deliver_explicit_reply_chunks( { outcome if delivered(&outcome) => {} WriteOutcome::Rejected { code, .. } => { - tracing::warn!(error_code = %code, "progressive explicit reply rejected"); - return ProgressiveDelivery::rejected(); + 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::Delivered { .. } | WriteOutcome::Unknown { .. } => { - tracing::warn!("progressive explicit reply outcome unknown"); - return ProgressiveDelivery::unknown(); + 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 + 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( @@ -311,7 +436,7 @@ pub(crate) async fn finalize_explicit_reply( ); } } - ProgressiveDelivery::default() + delivery } #[cfg(test)] @@ -471,6 +596,14 @@ mod tests { } } + 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); @@ -567,7 +700,10 @@ mod tests { ) .await; - assert_eq!(result, ProgressiveDelivery::unknown()); + assert_eq!( + result, + ProgressiveDelivery::unknown_chunk(0, 1, 0, "cosmetic_edit_unknown") + ); assert!(adapter.events().is_empty()); } @@ -675,7 +811,7 @@ mod tests { let result = finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; - assert_eq!(result, ProgressiveDelivery::unknown()); + assert_eq!(result, unknown_delivery(0, 1, 0)); assert_eq!(adapter.events(), vec!["edit:final"]); } @@ -689,7 +825,7 @@ mod tests { let result = finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; - assert_eq!(result, ProgressiveDelivery::unknown()); + assert_eq!(result, unknown_delivery(0, 1, 0)); assert_eq!(adapter.events(), vec!["edit:final", "delete"]); } @@ -705,7 +841,7 @@ mod tests { let result = finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; - assert_eq!(result, ProgressiveDelivery::rejected()); + assert_eq!(result, rejected_delivery(0, 1, 0)); assert_eq!(adapter.events(), vec!["edit:final", "delete", "send:final"]); } @@ -721,15 +857,15 @@ mod tests { let result = finalize_edit_placeholder(&erased, &channel(), &placeholder(), &["final".into()]).await; - assert_eq!(result, ProgressiveDelivery::unknown()); + 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(), ProgressiveDelivery::rejected()), - (unknown(), ProgressiveDelivery::unknown()), + (rejected(), rejected_delivery(0, 1, 0)), + (unknown(), unknown_delivery(0, 1, 0)), ] { let adapter = Arc::new(RecordingAdapter::new()); adapter.push_edit(rejected()); @@ -763,7 +899,7 @@ mod tests { ) .await; - assert_eq!(result, ProgressiveDelivery::rejected()); + assert_eq!(result, rejected_delivery(1, 3, 1)); assert_eq!(adapter.events(), vec!["edit:first", "send:second"]); } @@ -783,7 +919,7 @@ mod tests { ) .await; - assert_eq!(result, ProgressiveDelivery::unknown()); + assert_eq!(result, unknown_delivery(1, 3, 1)); assert_eq!(adapter.events(), vec!["edit:first", "send:second"]); } @@ -802,7 +938,7 @@ mod tests { ) .await; - assert_eq!(result, ProgressiveDelivery::rejected()); + assert_eq!(result, rejected_delivery(0, 1, 0)); assert_eq!(adapter.events(), vec!["reply:final"]); } @@ -821,15 +957,15 @@ mod tests { ) .await; - assert_eq!(result, ProgressiveDelivery::unknown()); + 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(), ProgressiveDelivery::rejected()), - (unknown(), ProgressiveDelivery::unknown()), + (rejected(), rejected_delivery(1, 3, 1)), + (unknown(), unknown_delivery(1, 3, 1)), ] { let adapter = Arc::new(RecordingAdapter::new()); adapter.push_reply(delivered("reply")); @@ -875,6 +1011,85 @@ mod tests { ); } + #[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()] { diff --git a/crates/openab-gateway/src/adapters/teams.rs b/crates/openab-gateway/src/adapters/teams.rs index a77ccea00..fb3dc3c7a 100644 --- a/crates/openab-gateway/src/adapters/teams.rs +++ b/crates/openab-gateway/src/adapters/teams.rs @@ -4500,6 +4500,12 @@ mod tests { .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( @@ -4524,6 +4530,18 @@ mod tests { } 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; diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index e8d3a21e4..0b43d30f9 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -279,6 +279,8 @@ impl AppState { /// 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(); @@ -326,7 +328,9 @@ impl AppState { can_edit: true, can_delete: true, show_streaming_placeholder: true, - message_limit: characters(4096), + message_limit: MessageLimit::Utf16Bytes { + max: TEAMS_TEXT_UTF16_BUDGET_BYTES, + }, supports_reactions: teams.reactions_enabled(), supports_attachment_materialization: teams.inbound_attachments_enabled(), status_backend: if teams.reactions_enabled() { @@ -2145,6 +2149,12 @@ mod gateway_protocol_tests { assert!(teams.supports_target_message_id); assert!(teams.supports_attachment_materialization); assert!(!teams.supports_reactions); + assert_eq!( + teams.message_limit, + schema::MessageLimit::Utf16Bytes { + max: schema::TEAMS_TEXT_UTF16_BUDGET_BYTES, + } + ); } #[cfg(feature = "teams")] diff --git a/crates/openab-gateway/src/schema.rs b/crates/openab-gateway/src/schema.rs index 31dfdb692..a9c1e091b 100644 --- a/crates/openab-gateway/src/schema.rs +++ b/crates/openab-gateway/src/schema.rs @@ -167,6 +167,10 @@ pub enum StreamingMode { 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 { @@ -767,6 +771,23 @@ mod protocol_tests { assert_eq!(decoded.status_backend, StatusBackend::Reactions); } + #[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(); diff --git a/docs/config-reference.md b/docs/config-reference.md index bc0619af0..7195cafef 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -247,6 +247,8 @@ Full first-class Teams section (config-first parity, #1380) — credentials, con > > `streaming = true` opts into a separate progressive content placeholder. It is enabled only after Standalone hello (or the Unified Teams adapter) proves real-ID send plus bot-owned edit/delete with required ACKs. The generic `[gateway].streaming` and Telegram settings never enable Teams. Unknown write outcomes suppress recovery sends to avoid duplicates; no Graph/RSC grant is used. Microsoft 365 live validation is still pending. > +> New Teams peers use a fixed 80,000 UTF-16-byte final-text budget based on Microsoft's conservative 80 KB recommendation; this is not a user setting. Old Gateway, no-hello, and unavailable Unified paths retain the 4,096-character fallback. Final chunks are sent sequentially under required ACK and stop at the first rejected or unknown outcome. Teams text-only messages do not render Markdown tables, so keep the default `[markdown].tables = "code"` or select `"bullets"`; `"off"` is an explicit raw-pipe bypass. +> > `inbound_attachments = true` enables metadata-first Teams image/text ingress. Gateway publishes only bounded metadata and an opaque process-local reference; Core requests bytes only after structural, typed L2, and L3 identity admission. URLs, query strings, and Microsoft credentials stay in Gateway. Inline images work in all scopes; Personal `file.download.info` image and UTF-8 text files additionally require a separate manifest profile with `supportsFiles: true`. Group-chat/channel paperclip files remain unsupported without Graph. Standalone requires the opt-in on both processes plus a valid Gateway hello advertising the additive materialization capability; malformed switches, old peers, route expiry, restart, scope mismatch, and oversized data fail closed without retry. > > Teams Personal, group-chat, and channel scope is derived from the authenticated Bot Framework activity. Presence of any of `allowed_teams`, `allowed_channels`, `allow_personal`, or `allow_group_chats` (or its environment variable) opts into typed L2 policy. With neither list populated, all Team channels are admitted; otherwise a Team **or** channel ID match admits the channel. Personal and group chats use their booleans. L3 user trust is still evaluated independently. The two boolean environment variables accept `true`/`false` or `1`/`0`; any other explicitly present value resolves to `false` (fail closed). diff --git a/docs/msteams-selfhosted.md b/docs/msteams-selfhosted.md index 6ecadd72d..618f96662 100644 --- a/docs/msteams-selfhosted.md +++ b/docs/msteams-selfhosted.md @@ -25,9 +25,13 @@ session_ttl_hours = 1 tool_display = "compact" [markdown] -tables = "off" +tables = "code" # default; Teams text-only activities do not render Markdown tables ``` +`tables = "code"` keeps tables readable as aligned fenced text before applying +Teams' 80,000 UTF-16-byte message budget. Use `"bullets"` for a linear fallback; +`"off"` intentionally sends raw pipes and does not claim native table rendering. + Set `TEAMS_APP_ID` and `TEAMS_APP_SECRET` on the container. No `[gateway]` needed. ### `[teams]` Section (credentials + typed scope + trust) diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 1ec17e3d4..4a94cf018 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -129,11 +129,14 @@ pr = "" [[openab_features]] feature = "message_split" -status = "partial" -note = "Core splits via `split_delivery`; `GatewayAdapter::message_limit()` returns 4096 (hardcoded 'Telegram limit', not Teams' ~100 KB / UTF-16 budget) — chunking works but the bound is generic, not Teams-tuned." +status = "implemented" +note = "Standalone and an available Unified Teams adapter advertise an exact 80,000 UTF-16-byte text budget; old Gateway, no-hello, and unavailable Unified paths keep the 4096-character fallback. Core applies table rendering before exact unit-aware splitting, balances fenced code blocks, and delivers required-ACK chunks sequentially until the first Rejected or Unknown outcome. Automated coverage passes; Microsoft 365 long-message presentation remains live-pending." source = [ - "crates/openab-core/src/adapter.rs#message_limit", - "crates/openab-core/src/gateway.rs", + "crates/openab-core/src/format.rs#split_message_with_budget", + "crates/openab-core/src/adapter.rs#stream_prompt_blocks", + "crates/openab-core/src/progressive.rs#deliver_required_ack_chunks", + "crates/openab-gateway/src/lib.rs#gateway_capabilities", + "src/unified_adapter.rs#capabilities", ] pr = "" @@ -357,6 +360,13 @@ note = "A confirmed send records `(app, tenant, conversation, activity)` ownersh kind = "openab_decision" source = "docs/adr/teams-owned-message-mutations.md" +[[quirks]] +date = "2026-08-10" +title = "OpenAB uses an exact conservative UTF-16 text budget" +note = "New Teams peers advertise 80,000 UTF-16 bytes (decimal) and Core measures final rendered text exactly, including synthetic code-fence markers. Required-ACK chunks stop at the first non-delivered outcome; Unknown never causes a retry or fresh warning activity. Legacy and no-hello peers remain at 4096 characters." +kind = "openab_decision" +source = "docs/adr/teams-formatting-and-long-messages.md" + [[quirks]] date = "2026-07-04" title = "Bot message budget is ~100 KB UTF-16" diff --git a/scripts/teams-ack-drop-proxy.py b/scripts/teams-ack-drop-proxy.py index 17cf96fc4..9f0f1e0b5 100755 --- a/scripts/teams-ack-drop-proxy.py +++ b/scripts/teams-ack-drop-proxy.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 """Drop one selected Microsoft Teams write ACK in a transparent WebSocket hop. -This repository-owned helper supports two bounded live-test targets: +This repository-owned helper supports three bounded live-test targets: * ``final-edit``: a Teams ``edit_message`` containing a unique marker and a real ``target_message_id``. * ``placeholder-send``: one of OpenAB Core's fixed placeholder payloads. +* ``overflow-send``: a fresh Teams send containing a unique marker, used to + target one deterministic overflow chunk after an earlier chunk was delivered. The selected command is always forwarded to Gateway. The proxy drops only its -first explicit ``Delivered`` ACK; placeholder ACKs additionally require a real -message ID. Rejected, Unknown, legacy, malformed, or duplicate ACKs are -forwarded and make the probe invalid rather than manufacturing ambiguity. +first explicit ``Delivered`` ACK; send ACKs additionally require a real message +ID. Rejected, Unknown, legacy, malformed, or duplicate ACKs are forwarded and +make the probe invalid rather than manufacturing ambiguity. The state file contains only timestamps, operation labels, counters, topology, and outcome classes. Request URLs, headers, markers, request/activity/channel @@ -43,7 +45,9 @@ MAX_FRAME_BYTES = 16 * 1024 * 1024 TARGET_FINAL_EDIT = "final-edit" TARGET_PLACEHOLDER_SEND = "placeholder-send" -TARGET_KINDS = (TARGET_FINAL_EDIT, TARGET_PLACEHOLDER_SEND) +TARGET_OVERFLOW_SEND = "overflow-send" +TARGET_KINDS = (TARGET_FINAL_EDIT, TARGET_PLACEHOLDER_SEND, TARGET_OVERFLOW_SEND) +MARKER_TARGET_KINDS = frozenset({TARGET_FINAL_EDIT, TARGET_OVERFLOW_SEND}) PLACEHOLDER_TEXTS = frozenset( { "…", @@ -328,6 +332,8 @@ def is_target_request( ) if target_kind == TARGET_PLACEHOLDER_SEND: return message.get("command") is None and text in PLACEHOLDER_TEXTS + if target_kind == TARGET_OVERFLOW_SEND: + return message.get("command") is None and marker is not None and marker in text raise ValueError("unsupported target kind") @@ -352,7 +358,7 @@ def drop_eligible_ack(message: dict[str, Any], target_kind: str) -> bool: isinstance(success, bool) and success ): return False - if target_kind == TARGET_PLACEHOLDER_SEND: + if target_kind in {TARGET_PLACEHOLDER_SEND, TARGET_OVERFLOW_SEND}: message_id = message.get("message_id") return isinstance(message_id, str) and bool(message_id) return target_kind == TARGET_FINAL_EDIT @@ -523,9 +529,11 @@ def validate_arguments( parser.error("listen port is out of range") if not 1 <= arguments.upstream_port <= 65535: parser.error("upstream port is out of range") - if arguments.target_kind == TARGET_FINAL_EDIT: + if arguments.target_kind in MARKER_TARGET_KINDS: if arguments.marker is None or not 12 <= len(arguments.marker) <= 128: - parser.error("final-edit requires a unique 12-128 character marker") + parser.error( + f"{arguments.target_kind} requires a unique 12-128 character marker" + ) elif arguments.marker is not None: parser.error("placeholder-send does not accept a marker") state_path = Path(arguments.state_file) diff --git a/scripts/test-teams-ack-drop-proxy.py b/scripts/test-teams-ack-drop-proxy.py index 64e205f59..a4a381e31 100755 --- a/scripts/test-teams-ack-drop-proxy.py +++ b/scripts/test-teams-ack-drop-proxy.py @@ -2,7 +2,7 @@ """Offline regression suite for ``teams-ack-drop-proxy.py``. The suite uses raw loopback sockets and a fake Gateway. It never contacts a live -deployment and verifies both target modes, explicit-Delivered eligibility, +deployment and verifies all three target modes, explicit-Delivered eligibility, process-wide claiming, post-target classification, handshake coalescing, state permissions, and sensitive-field non-persistence. """ @@ -205,6 +205,20 @@ def placeholder_send(request_id: str = REQUEST_SENTINEL) -> dict[str, Any]: } +def overflow_send(request_id: str = REQUEST_SENTINEL) -> dict[str, Any]: + return { + "schema": "openab.gateway.reply.v1", + "platform": "teams", + "command": None, + "request_id": request_id, + "reply_to": "fixture-origin-id", + "content": { + "type": "text", + "text": "deterministic overflow " + MARKER_SENTINEL, + }, + } + + class FakeGateway: def __init__( self, @@ -324,7 +338,7 @@ def run_proxy_case( "--state-file", state_path.name, ] - if target_kind == PROXY.TARGET_FINAL_EDIT: + if target_kind in PROXY.MARKER_TARGET_KINDS: arguments.extend(("--marker", MARKER_SENTINEL)) process = subprocess.Popen( arguments, @@ -452,6 +466,24 @@ def test_target_classifiers_are_narrow(self) -> None: self.assertFalse( PROXY.is_target_request(final_text, PROXY.TARGET_PLACEHOLDER_SEND, None) ) + self.assertTrue( + PROXY.is_target_request( + overflow_send(), PROXY.TARGET_OVERFLOW_SEND, MARKER_SENTINEL + ) + ) + self.assertFalse( + PROXY.is_target_request( + overflow_send(), PROXY.TARGET_OVERFLOW_SEND, "different-marker" + ) + ) + overflow_edit = overflow_send() + overflow_edit["command"] = "edit_message" + overflow_edit["target_message_id"] = "fixture-target-id" + self.assertFalse( + PROXY.is_target_request( + overflow_edit, PROXY.TARGET_OVERFLOW_SEND, MARKER_SENTINEL + ) + ) def test_drop_eligibility_requires_explicit_delivered(self) -> None: delivered_edit = delivered_ack(REQUEST_SENTINEL) @@ -467,6 +499,9 @@ def test_drop_eligibility_requires_explicit_delivered(self) -> None: self.assertTrue( PROXY.drop_eligible_ack(delivered_send, PROXY.TARGET_PLACEHOLDER_SEND) ) + self.assertTrue( + PROXY.drop_eligible_ack(delivered_send, PROXY.TARGET_OVERFLOW_SEND) + ) self.assertFalse( PROXY.drop_eligible_ack( rejected_ack(REQUEST_SENTINEL), PROXY.TARGET_FINAL_EDIT @@ -545,6 +580,12 @@ def test_argument_guards_reject_unsafe_activation(self) -> None: "--marker", MARKER_SENTINEL, ], + "missing overflow marker": [ + "--listen-host", + "127.0.0.1", + "--target-kind", + PROXY.TARGET_OVERFLOW_SEND, + ], "state path traversal": [ "--listen-host", "127.0.0.1", @@ -596,6 +637,29 @@ def test_placeholder_delivered_ack_with_real_id_is_dropped(self) -> None: self.assertEqual(result.state["post_target_content_commands"], []) self.assertEqual(result.state["post_target_reaction_commands"], 0) + def test_overflow_send_delivered_ack_with_real_id_is_dropped(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_OVERFLOW_SEND, + command=overflow_send(), + target_ack=delivered_ack( + REQUEST_SENTINEL, message_id=fixture_message_reference() + ), + ) + self.assertEqual(result.state["target_kind"], "overflow_send") + self.assertTrue(result.state["target_ack_dropped"]) + self.assertEqual(result.state["dropped_ack_outcome"], "delivered") + self.assertEqual(result.state["post_target_content_commands"], []) + + def test_overflow_send_delivered_without_real_id_is_forwarded(self) -> None: + result = self.run_proxy_case( + target_kind=PROXY.TARGET_OVERFLOW_SEND, + command=overflow_send(), + target_ack=delivered_ack(REQUEST_SENTINEL), + ) + self.assertFalse(result.state["target_ack_dropped"]) + self.assertTrue(result.state["target_ack_forwarded"]) + self.assertEqual(result.state["forwarded_ack_outcome"], "delivered") + def test_placeholder_delivered_without_real_id_is_forwarded(self) -> None: result = self.run_proxy_case( target_kind=PROXY.TARGET_PLACEHOLDER_SEND, diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index c1547d9cb..92b77db58 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -12,7 +12,7 @@ use openab_core::adapter::{WriteFailure, WriteOutcome as CoreWriteOutcome}; use openab_core::gateway::apply_teams_progressive_capabilities; #[cfg(feature = "teams")] use openab_gateway::schema::WriteOutcome; -use openab_gateway::schema::{Content, GatewayReply, ReplyChannel}; +use openab_gateway::schema::{Content, GatewayReply, ReplyChannel, TEAMS_TEXT_UTF16_BUDGET_BYTES}; use openab_gateway::AppState; use std::collections::HashMap; use std::sync::Arc; @@ -354,6 +354,9 @@ impl ChatAdapter for UnifiedGatewayAdapter { && self.gw_state.telegram_rich_messages), message_limit: match platform { "acp" => MessageLimit::Unlimited, + "teams" if teams_available => MessageLimit::Utf16Bytes { + max: TEAMS_TEXT_UTF16_BUDGET_BYTES, + }, "lineworks" => MessageLimit::Characters { max: 2000 }, "wecom" => MessageLimit::Characters { max: 2048 }, _ => MessageLimit::Characters { max: 4096 }, @@ -572,6 +575,11 @@ mod tests { assert!(capabilities.can_delete); assert!(!capabilities.supports_reactions); assert_eq!(capabilities.status_backend, StatusBackend::None); + assert_eq!( + capabilities.message_limit, + MessageLimit::Characters { max: 4096 }, + "an unavailable embedded Teams adapter keeps the conservative fallback" + ); let (event_tx, _event_rx) = tokio::sync::broadcast::channel(4); let mut state = AppState::test_default(event_tx); @@ -599,6 +607,12 @@ mod tests { reaction_capabilities.status_backend, StatusBackend::Reactions ); + assert_eq!( + reaction_capabilities.message_limit, + MessageLimit::Utf16Bytes { + max: TEAMS_TEXT_UTF16_BUDGET_BYTES, + } + ); let message_adapter = UnifiedGatewayAdapter::new(state) .with_teams_processing_indicator(true) From 684d0f8cd5264d8762a5cc0c5521f409faffa347 Mon Sep 17 00:00:00 2001 From: Neo Hsu Date: Fri, 21 Aug 2026 01:03:54 +0800 Subject: [PATCH 16/16] feat(teams): add text command parity --- crates/openab-core/src/acp/connection.rs | 93 +- crates/openab-core/src/acp/pool.rs | 165 +- crates/openab-core/src/commands.rs | 1116 ++++++ crates/openab-core/src/discord.rs | 558 +-- crates/openab-core/src/dispatch.rs | 237 +- crates/openab-core/src/gateway.rs | 675 ++-- crates/openab-core/src/lib.rs | 1 + crates/platform-schema/Cargo.lock | 851 ++++- crates/platform-schema/Cargo.toml | 4 + .../testdata/MicrosoftTeams.v1.25.schema.json | 3313 +++++++++++++++++ crates/platform-schema/testdata/README.md | 14 + .../platform-schema/tests/teams_manifest.rs | 145 + docs/msteams-enterprise.md | 34 +- docs/msteams-selfhosted.md | 34 +- .../examples/teams-manifest-v1.25.json | 99 + docs/platforms/schema/teams.toml | 9 +- docs/slash-commands.md | 58 +- 17 files changed, 6769 insertions(+), 637 deletions(-) create mode 100644 crates/openab-core/src/commands.rs create mode 100644 crates/platform-schema/testdata/MicrosoftTeams.v1.25.schema.json create mode 100644 crates/platform-schema/testdata/README.md create mode 100644 crates/platform-schema/tests/teams_manifest.rs create mode 100644 docs/platforms/examples/teams-manifest-v1.25.json 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/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/discord.rs b/crates/openab-core/src/discord.rs index 609bd7459..4df3e45cf 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; @@ -718,8 +722,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. @@ -1123,7 +1126,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 { @@ -1364,7 +1367,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 +1488,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 +1540,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 +1578,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 +1760,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 +1776,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 +1820,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 +1899,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 +2531,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 +2559,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"); } } } @@ -2984,8 +3053,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 +3152,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 +3307,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 +3319,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 +3392,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 { @@ -4413,6 +4528,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 c75223c97..51fa6097c 100644 --- a/crates/openab-core/src/dispatch.rs +++ b/crates/openab-core/src/dispatch.rs @@ -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, @@ -1180,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] @@ -1227,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) { @@ -1250,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 / @@ -1317,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] @@ -1326,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(); } @@ -1343,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] @@ -1352,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(); @@ -1400,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() } } @@ -1423,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) @@ -1441,12 +1535,20 @@ 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(()) @@ -1582,7 +1684,7 @@ mod tests { 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); @@ -1725,7 +1827,7 @@ mod tests { parent_id: None, origin_event_id: Some("evt-fresh".into()), }; - tx.send(msg).await.unwrap(); + assert!(tx.send(msg).await.is_ok()); drop(tx); consumer_loop( @@ -1823,7 +1925,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/gateway.rs b/crates/openab-core/src/gateway.rs index 7835e0c71..d686b6ba9 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1,9 +1,9 @@ use crate::acp::ContentBlock; use crate::adapter::{ - AdapterCapabilities, AdapterRouter, ChannelRef, ChatAdapter, MaterializedAttachment, - MessageLimit, MessageRef, SenderContext, StatusBackend, StreamingMode, WriteFailure, - WriteOutcome, WriteOutcomeKind, + AdapterCapabilities, ChannelRef, ChatAdapter, MaterializedAttachment, MessageLimit, MessageRef, + 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}; @@ -455,6 +455,64 @@ fn strip_recipient_mention(event: &GatewayEvent) -> String { 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, + )); +} + +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, @@ -997,162 +1055,6 @@ impl GatewayAdapter { } } -/// 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 { - 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(), - }, - command: None, - request_id: None, - quote_message_id: None, - target_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, - )); - } - } - - 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)); - } - lines.push(format!("\nUsage: /{label} set ")); - Some(lines.join("\n")) - } - "set" => { - if arg.is_empty() { - return Some(format!("Usage: /{label} set ")); - } - // 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())); - } - } - // 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}")), - }; - } - } - Some(format!( - "⚠️ No {label} matching \"{arg}\". Use /{label} list to see options." - )) - } - _ => Some(format!( - "Unknown action \"{action}\". Usage: /{label} list | /{label} set " - )), - } -} - #[async_trait] impl ChatAdapter for GatewayAdapter { fn platform(&self) -> &'static str { @@ -1662,7 +1564,6 @@ pub async fn run_gateway_adapter( gateway_ack_timeout_secs, }, )); - let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); // Hoist filter params outside loop — all fields are loop-invariant. @@ -1796,13 +1697,6 @@ pub async fn run_gateway_adapter( let prompt = strip_recipient_mention(&event); - info!( - platform = %event.platform, - sender = %event.sender.name, - channel = %redact_channel(&event.channel.id), - "gateway event received" - ); - let channel = ChannelRef { platform: event.platform.clone(), channel_id: event.channel.id.clone(), @@ -1811,6 +1705,30 @@ pub async fn run_gateway_adapter( 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(), @@ -2001,41 +1919,6 @@ pub async fn run_gateway_adapter( continue; } - // 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; - 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 let thread_channel = if event.channel.channel_type == "supergroup" @@ -2165,7 +2048,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, _ => { @@ -2317,13 +2202,6 @@ pub async fn process_gateway_event( let prompt = strip_recipient_mention(&event); - tracing::info!( - platform = %event.platform, - sender = %event.sender.name, - channel = %redact_channel(&event.channel.id), - "gateway event received (unified)" - ); - let channel = ChannelRef { platform: event.platform.clone(), channel_id: event.channel.id.clone(), @@ -2332,6 +2210,20 @@ pub async fn process_gateway_event( 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(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(), @@ -2570,38 +2462,6 @@ pub async fn process_gateway_event( return Ok(false); } - // Slash command interception - 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); - } - 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}"), - }; - 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); - } - } - // Submit to dispatcher let adapter = ctx.adapter.clone(); let dispatcher = ctx.dispatcher.clone(); @@ -2670,13 +2530,25 @@ fn format_size(n: u64) -> String { #[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 { materializations: AtomicUsize, sends: AtomicUsize, + messages: std::sync::Mutex>, + } + + impl AttachmentProbeAdapter { + fn messages(&self) -> std::sync::MutexGuard<'_, Vec> { + self.messages + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } } #[async_trait] @@ -2716,8 +2588,9 @@ mod tests { }) } - async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result { + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { self.sends.fetch_add(1, Ordering::SeqCst); + self.messages().push(content.to_string()); Ok(MessageRef { channel: channel.clone(), message_id: "echo".into(), @@ -2742,6 +2615,122 @@ mod tests { } } + 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, + } + + impl Default for BlockingOutcomeAdapter { + fn default() -> Self { + Self { + sends: AtomicUsize::new(0), + started: tokio::sync::Semaphore::new(0), + 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 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.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", @@ -2790,6 +2779,171 @@ mod tests { .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 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.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + assert!(probe.messages()[0].contains("Nothing to cancel")); + 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!(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!(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.materializations.load(Ordering::SeqCst), 0); + assert_eq!(probe.sends.load(Ordering::SeqCst), 1); + 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, + 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, + 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 identity_denial_precedes_attachment_materialization() -> anyhow::Result<()> { let pool = Arc::new(crate::acp::SessionPool::new( @@ -2814,10 +2968,7 @@ mod tests { crate::dispatch::BatchGrouping::Thread, std::time::Duration::from_secs(1), )); - let probe = Arc::new(AttachmentProbeAdapter { - materializations: AtomicUsize::new(0), - sends: AtomicUsize::new(0), - }); + let probe = Arc::new(AttachmentProbeAdapter::default()); let adapter: Arc = probe.clone(); let context = GatewayEventContext { adapter, @@ -2849,10 +3000,7 @@ mod tests { crate::dispatch::BatchGrouping::Thread, std::time::Duration::from_secs(60), )); - let probe = Arc::new(AttachmentProbeAdapter { - materializations: AtomicUsize::new(0), - sends: AtomicUsize::new(0), - }); + let probe = Arc::new(AttachmentProbeAdapter::default()); let mut context = GatewayEventContext { adapter: probe.clone(), dispatcher, @@ -3106,15 +3254,16 @@ mod tests { #[test] fn legacy_and_structured_gateway_responses_map_to_write_outcomes() { - let legacy: GatewayResponse = serde_json::from_value(serde_json::json!({ + 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 - })) - .unwrap(); + })) else { + panic!("legacy response fixture must decode"); + }; assert_eq!( legacy.write_outcome(), WriteOutcome::Delivered { @@ -3122,7 +3271,7 @@ mod tests { } ); - let unknown: GatewayResponse = serde_json::from_value(serde_json::json!({ + let Ok(unknown): Result = serde_json::from_value(serde_json::json!({ "schema": "openab.gateway.response.v1", "request_id": "req-new", "success": false, @@ -3131,8 +3280,9 @@ mod tests { "error": "delivery may have completed", "outcome": "unknown", "error_code": "request_timeout" - })) - .unwrap(); + })) else { + panic!("structured response fixture must decode"); + }; assert_eq!( unknown.write_outcome(), WriteOutcome::Unknown { @@ -3175,7 +3325,9 @@ mod tests { #[test] fn client_hello_wire_shape_is_additive_and_versioned() { - let value = serde_json::to_value(build_client_hello()).unwrap(); + 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"] @@ -3251,7 +3403,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": "", @@ -3261,7 +3413,10 @@ 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 { diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index aa6b87f7b..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; 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