From 8778e02270f2a8b5f71644edc30329190e04bc7d Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 17:03:29 +0000 Subject: [PATCH 01/37] docs(platforms): add capability matrix skeleton --- docs/platforms/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/platforms/README.md diff --git a/docs/platforms/README.md b/docs/platforms/README.md new file mode 100644 index 000000000..f27f0ae2b --- /dev/null +++ b/docs/platforms/README.md @@ -0,0 +1,22 @@ +# Messaging platforms — capability matrix + +Engineering-facing reference for how each messaging platform behaves and how OpenAB's adapters map it. **Distinct from** the operator setup guides in `docs/.md` — this tree is for maintainers/reviewers. + +Each platform has its own page with three parts: **(A) platform-intrinsic facts** (official-doc sourced), **(B) OpenAB mapping** (`file:line` + PR sourced), and a **findings log**. See [`line.md`](./line.md) for the worked example. + +**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — type (A) facts link the official platform doc; type (B) decisions link the PR/issue where the reasoning lives. + +## Matrix + +| Aspect | LINE | Slack | Telegram | Feishu | WeCom | Google Chat | Teams | Discord | +|---|---|---|---|---|---|---|---|---| +| Transport | gateway | Socket Mode | gateway | unified/gateway | gateway | gateway | gateway | native WS | +| L1 auth | HMAC-SHA256 | app_token | secret_token + IP | SHA256 + encrypt key | Token sig + AES-256-CBC | JWT RS256 (JWKS) | JWT OIDC (JWKS) | bot_token | +| Reply/echo model | Reply(≈50s, free) / Push(quota) | chat API | send | send | send | send | send | send | +| Group sender ID | absent w/o consent → `unknown` | present | present | present | UserID | `users/...` | `activity.from.id` | present | +| Display name in event | ✗ (Profile API) | ✓ | ✓ | ✓ | ? | ? | ✓ | ✓ | +| Mention model | `mention.isSelf` | ? | bot_username | ? | ? | ? | ? | @mention + multibot | +| Bot-to-bot delivery | ✗ (`is_bot` always false) | ✓ | ✓ | ? | ? | ? | ? | ✓ (trusted_bot_ids) | +| Per-platform page | [line.md](./line.md) | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | + +Cells marked `?` / `TODO` are owned by that platform's maintainer — please fill from your adapter and official docs (don't guess; leave `?` if unverified). Only LINE is verified so far (against `crates/openab-gateway/src/adapters/line.rs`). From fe294a126829d3ed242734b71dc1fa38365cc674 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 17:03:29 +0000 Subject: [PATCH 02/37] docs(platforms): seed LINE notes from ADR #1291 review --- docs/platforms/line.md | 57 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/platforms/line.md diff --git a/docs/platforms/line.md b/docs/platforms/line.md new file mode 100644 index 000000000..a8878ef3f --- /dev/null +++ b/docs/platforms/line.md @@ -0,0 +1,57 @@ +# LINE — platform notes + +Engineering-facing capability & quirks reference for the LINE adapter. For operator setup, see the LINE setup guide. This doc is maintained by the LINE maintainer. + +> Structure: **(A) platform-intrinsic facts** (official-doc sourced) · **(B) OpenAB mapping** (code + PR sourced) · **Findings log**. See [`README.md`](./README.md) for the cross-platform matrix. + +--- + +## (A) Platform-intrinsic facts + +Provider truths, independent of OpenAB. Source of authority = LINE official docs. + +| Aspect | Behavior | Official ref | +|---|---|---| +| L1 auth | Webhook body signed with HMAC-SHA256 over the raw body using the channel secret; sent in `X-Line-Signature` | [Signature validation](https://developers.line.biz/en/reference/messaging-api/#signature-validation) | +| Reply model | `replyToken` per webhook event — **single-use** and **short-lived** (must reply within ~1 min). Free, does not consume quota | [Send reply message](https://developers.line.biz/en/reference/messaging-api/#send-reply-message) | +| Push model | `POST /message/push` by `userId` — works without a reply token, but **consumes the monthly message quota** (paid beyond the free tier) | [Send push message](https://developers.line.biz/en/reference/messaging-api/#send-push-message) · [Pricing](https://developers.line.biz/en/docs/messaging-api/overview/) | +| Group identity | In group/room events, `source.userId` is **only present when the user consents to providing their info**; otherwise it is absent | [Source object](https://developers.line.biz/en/reference/messaging-api/#source-user) | +| Display name | **Not** included in webhooks. Must be fetched via Profile API (`GET /profile/{userId}`, group variant `GET /group/{groupId}/member/{userId}/profile`). Profile API resolves `userId → name`; it **cannot** recover a missing `userId` | [Get profile](https://developers.line.biz/en/reference/messaging-api/#get-profile) · [Group member profile](https://developers.line.biz/en/reference/messaging-api/#get-group-member-profile) | +| Bot-to-bot | LINE does not deliver other bots' messages to your webhook — no bot-message path | [Webhook events](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) | +| Mention | Group/room text events carry a `mention` object; the bot's own mentionee has `isSelf = true` (no bot-username env var needed) | [Message event / mention](https://developers.line.biz/en/reference/messaging-api/#wh-text) | + +## (B) OpenAB mapping + +How the adapter (in the **gateway** crate) implements the above. Refs are `crates/openab-gateway/src/adapters/line.rs` unless noted. + +| Aspect | Implementation | Ref | +|---|---|---| +| L1 verify | HMAC-SHA256 over raw body vs `X-Line-Signature`; reject on missing/invalid | `line.rs:84-103` | +| Reply/Push dispatch | `dispatch_line_reply()` — hybrid: try Reply (cached token), fall back to Push when token expired/consumed. **Lives in the gateway crate**, not core | `line.rs:646` | +| Reply-token cache | `ReplyTokenCache`, TTL `REPLY_TOKEN_TTL_SECS = 50`, cap `REPLY_TOKEN_CACHE_MAX = 10_000` | `lib.rs:17`, `lib.rs:20` | +| Identity normalize | 1:1 → `channel_id = userId`; group → `group_id`; room → `room_id`. Sender `userId` falls back to `"unknown"` when absent | `line.rs:333-354` | +| SenderInfo | `id`/`name`/`display_name` all set to the raw `userId` (no name resolution today); `is_bot` hardcoded `false` | `line.rs:388-391` | +| @mention gating | Group/room messages that don't mention the bot are dropped **during normalization** (upstream of any trust check) | `line.rs:373-380` | +| Current trust | Shared gateway `should_skip_event()` in core — no LINE-specific trust today | `openab-core/src/gateway.rs:832` | + +## Trust / echo design (agreed for the ADR #1291 revision) + +- Trust decision in **core**; echo **delivery delegated to the gateway adapter** (LINE reuses `dispatch_line_reply`). +- deny-echo is **Reply-only, never Push** (no valid token → drop silently) to avoid push-quota DoS. +- Group config: `default_group_policy` + per-group `policy` (`open` = any member who @mentions; `members` = must be in `allowed_users`). `"unknown"` is always deny and never allowlistable. +- @mention gating stays **upstream** of the trust gate. +- Echo scope: 1:1 includes the sender UID; group/room carries **no ID** (generic message); both hard rate-limited. +- Name resolution enhancement: Profile API + local cache keyed by `userId` (long TTL; respects Profile API rate limit). + +--- + +## Findings log + +Newest first. Type (A) → official-doc link; type (B) → PR/issue link. + +- **2026-07-04** (B) deny-echo on LINE must be **Reply-only, never fall back to Push** — reply token dies in ~50s, so echoing denies to a spammer would mostly hit Push and burn the paid quota (DoS amplification). [PR #1291] +- **2026-07-04** (B) Trust decision in core, but echo **delivery** delegated to the gateway adapter — LINE's send path (`dispatch_line_reply`) lives in the gateway crate, so "core does the echo" doesn't hold. [PR #1291] +- **2026-07-04** (A/B) In groups, `allowed_users` is unreliable because `userId` may be absent (`"unknown"`). Two-mode group config (`open`/`members`); `"unknown"` is never allowlistable. [PR #1291] +- **2026-07-04** (B) @mention gating must stay **upstream** of the trust gate — downstream would deny-echo ordinary group chatter not addressed to the bot. [PR #1291] +- **2026-07-04** (A) Profile API resolves `userId → name` only; it cannot recover a missing `userId` (chicken-and-egg). Name resolution + local cache fixes readable names, not the genuine `"unknown"` case. [Get profile](https://developers.line.biz/en/reference/messaging-api/#get-profile) +- **2026-07-04** (A) `is_bot` is always false for LINE — no bot-to-bot webhook delivery, so bot-bypass trust semantics are a no-op here. [Webhook events](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) From f14981bc0423c1b07a485882ff818e985ac8fc1b Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:07 +0000 Subject: [PATCH 03/37] docs(platforms): docs/platforms/wecom.md (schema v1) --- docs/platforms/wecom.md | 91 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/platforms/wecom.md diff --git a/docs/platforms/wecom.md b/docs/platforms/wecom.md new file mode 100644 index 000000000..da785fc02 --- /dev/null +++ b/docs/platforms/wecom.md @@ -0,0 +1,91 @@ +--- +platform: wecom +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# WeCom (企業微信) — platform notes + +Engineering-facing capability & quirks reference for the WeCom adapter. For operator setup see `docs/wecom.md`. Follows the schemas in [`README.md`](./README.md). + +WeCom is integrated via the **self-built app (自建应用 / agentid) callback model**, not the newer "智能机器人 / 群机器人" model. The adapter (`crates/openab-gateway/src/adapters/wecom.rs`) receives a user's 1:1 message via an AES-encrypted callback and replies proactively via `/cgi-bin/message/send`. + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| `transport` | webhook (HTTP callback; WeCom POSTs AES-encrypted XML to the configured callback URL) | [self-built app receive overview](https://developer.work.weixin.qq.com/document/path/90238) | +| `inbound_auth` | L1: `msg_signature` = SHA1 of `sort(token, timestamp, nonce, encrypt).concat()`; body is AES-256-CBC decrypted with the 43-char `EncodingAESKey` (base64→32 bytes), IV = first 16 key bytes, WeCom PKCS7 block_size=32; inner corp_id suffix validated (`decrypt_message`, `wecom.rs:99` sig, `wecom.rs:125` decrypt) | [receive / callback doc](https://developer.work.weixin.qq.com/document/path/90238) | +| `threads` | none — self-built app callback is a flat 1:1 conversation; no thread/topic primitive | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `slash_commands` | Not a platform primitive. No command registration/delivery; text like `/reset` arrives as ordinary `text` content and any interpretation is OpenAB-side | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `mentions` | n/a in the self-built app 1:1 model — every callback is a direct message from one `FromUserName`; no @mention concept. (Group @mention exists only under the separate 智能机器人 model, which this adapter does not use) | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `emoji_reactions` | No reaction API for self-built apps: bot cannot **add** or **remove** reactions, and reaction events are **not** delivered. The receive doc enumerates only six inbound callback types: text, image, voice, video, location, link (no reaction, no edit) | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `edit_message` | No edit API. The only mutation is **recall** (`/cgi-bin/message/recall`), which deletes rather than edits, and only within 24h on messages this app sent | [撤回应用消息](https://developer.work.weixin.qq.com/document/path/94867) | +| `delete_message` | Own messages only, via recall (`/cgi-bin/message/recall`), within **24 hours** of send; cannot delete users'/others' messages (data already delivered to the WeChat-plugin end also can't be pulled back) | [撤回应用消息](https://developer.work.weixin.qq.com/document/path/94867) | +| `rich_content` | Supported outbound msgtypes: text, image, voice, video, file, **textcard**, **news (图文)**, **mpnews**, **markdown**, miniprogram_notice, **template_card**. Adapter uses only `text` | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | +| `attachments` | Outbound media upload caps (`media/upload`): image ≤10MB (JPG/PNG), voice ≤2MB/60s (AMR), video ≤10MB (MP4), general file ≤20MB; min 5 bytes; temp media_id valid 3 days. Inbound: image/voice/video callbacks carry a `MediaId` pulled via `media/get` (same 3-day validity). Note: the receive doc's six enumerated inbound types are text/image/voice/video/location/link — inbound *file* (`MediaId` + `FileName`) is not in that list yet is delivered in practice and handled by the adapter | [上传临时素材](https://developer.work.weixin.qq.com/document/path/90253) · [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `message_length_limit` | `text` content ≤ **2048 bytes** ("超过将截断" — server truncates; ~680 CJK chars at 3 bytes each). Chunking required for long replies | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | +| `dm_support` | Yes — the self-built app model *is* 1:1 (app ↔ member via `touser`) | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | +| `group_model` | Self-built app has no group callback. A separate `appchat` (群聊) send API and the 智能机器人 model exist but are not used by this adapter | [appchat/send](https://developer.work.weixin.qq.com/document/path/90248) | +| `group_sender_identity` | n/a for this adapter (1:1 only). In 1:1, the stable sender id is `FromUserName` (member UserID), always present, no consent gate | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `send_model` | **Push** — app calls `message/send` with `access_token` + `agentid` + `touser`. No reply-window / reply-token; a user need not have messaged first | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | +| `proactive_push` | Allowed and unsolicited. Quotas: per app ≤ (account cap × 200) person-times/day; per app→member ≤ 30/min and 1000/hour (excess "会被丢弃不下发" — silently dropped) | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | +| `bot_to_bot` | n/a — self-built app callbacks originate from human members (`FromUserName` UserID); the platform does not deliver other apps'/bots' messages to a self-built app | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | +| `typing_indicator` | Not supported by the API | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| `send_message` | implemented | `message/send` msgtype=text with `agentid` + `touser`; token cached & auto-refreshed, retries once on errcode 42001 | `wecom.rs:558` (`send_text`), `wecom.rs:584` (`post_with_token_retry`) | +| `message_split/chunking` | implemented | Byte-aware split at 2048 (WeCom's server-side byte cap); prefers `\n` boundaries, splits over-long lines at UTF-8 char boundaries (`char_indices`) so multibyte chars aren't severed. Uses local `split_text_lines`, not the trait `split_delivery` | `wecom.rs:517` (call), `wecom.rs:840` (`split_text_lines`); `adapter.rs:149` (trait `split_delivery`) | +| `streaming` | workaround | No edit API in callback mode, so "streaming" = optional "⏳..." placeholder + debounce-buffer chunks into a `watch` channel + recall placeholder + resend consolidated text (`flush_thinking`). Causes client flicker → **default OFF** (`WECOM_STREAMING_ENABLED`, `debounce_secs=3`). With streaming off, chunks buffer silently and one consolidated message is sent | `wecom.rs:38-47` (config), `wecom.rs:381-459` (buffer/spawn), `wecom.rs:773` (`flush_thinking`) | +| `reply/quote` | n/a | No reply/quote primitive in the self-built app model. The trait `send_message_with_reply` default just falls back to plain send; adapter never wires it | `adapter.rs:336` (trait default) | +| `edit_message` | workaround | `reply.command == "edit_message"` only pushes new text into an in-flight streaming `watch` channel (`handle_edit_message`); there is no real WeCom edit. Outside a pending stream it is a no-op. The trait `edit_message` default (returns "not supported") otherwise applies | `wecom.rs:357` (dispatch), `wecom.rs:546` (`handle_edit_message`); `adapter.rs:330` (trait default) | +| `delete_message` | not-implemented | Recall API exists (24h window) but adapter never calls `/cgi-bin/message/recall` for user-facing delete — only internally to remove the thinking placeholder. Trait `delete_message` default edits to zero-width space, which WeCom can't honor | `wecom.rs:786` (internal recall only); `adapter.rs:353` (trait default) | +| `emoji_reactions` | n/a | `reply.command` `add_reaction` / `remove_reaction` are explicitly matched and ignored with a log line — WeCom self-built apps have no reaction API | `wecom.rs:351-356` | +| `threads/topics` | n/a | `create_topic` command explicitly ignored (logged). No thread primitive on platform | `wecom.rs:351-356` | +| `media_inbound` | partial | Inbound `image` → download over HTTPS-only (SSRF guard), reject >10MB, resize ≤1200px + JPEG q75 (GIF passthrough). `file` → `media/get` (retry on 42001), reject >20MB, **text files only** (extension/filename allowlist) and must be valid UTF-8; binary/office files rejected. voice/video/location/link msgtypes are dropped (only text/image/file forwarded) | `wecom.rs:1103` (`download_wecom_image`), `wecom.rs:1249` (`fetch_media_with_retry`), `wecom.rs:1287` (`download_wecom_file`), `wecom.rs:1234` (`is_text_file`), `wecom.rs:1410` (`resize_and_compress`) | +| `voice_stt` | not-implemented | `voice` msgtype is not in the accepted set (`text\|image\|file`); voice callbacks are dropped, no STT | `wecom.rs:1022` | +| `trust_gate` | implemented | Shared. Gateway ingress gate `gate_incoming` (L2 scope + L3 identity) runs before dispatch; wecom events carry `sender.id = FromUserName` (UserID) and `channel.id = wecom:{corp_id}:{from_user}` for keying | `wecom.rs:1059-1076` (event build); `gateway.rs:1196-1198` (gate call); `adapter.rs:495` (`gate_incoming` def) | +| `deny_echo` | implemented | Shared. On `DenyIdentity` the gateway echoes the sender their UserID with a request-access hint (throttled via `echo_allowed`); `DenyScope` silently drops. Platform-agnostic path, applies to wecom replies | `gateway.rs:1201-1226` | +| `mention_gating` | n/a | Shared `@mention` gating only fires for group/supergroup channel_type; wecom events are `channel_type="direct"`, so gating is bypassed by design | `wecom.rs:1064`; `gateway.rs:72-80` | +| `slash_commands` | partial | Shared. No platform slash mechanism; commands arrive as plain text and are handled by OpenAB's generic command layer, not in the wecom adapter | `wecom.rs:1030-1035` | +| `multibot` | n/a | Self-built app 1:1 callbacks come only from human members (`is_bot: false`); no other-bot delivery, no multi-bot channel | `wecom.rs:1067-1072` | +| `group_routing` | partial | Sessions keyed by `wecom:{corp_id}:{from_user}` (per-user 1:1); no group routing since there are no group callbacks in this model | `wecom.rs:1059` | + +## 3. Platform quirks (`platform-quirks` v1) + +### Send / push model (no reply window) + +WeCom self-built apps are pure push: given a valid `access_token` + `agentid`, the app can message any member via `touser` at any time — there is no LINE-style reply token or reply window. `access_token` (7200s TTL) is cached with a 300s refresh margin (`TOKEN_REFRESH_MARGIN_SECS`, `wecom.rs:225`) and force-refreshed on errcode 42001 across both `message/send` and `media/get`. + +### Crypto / callback specifics + +- `EncodingAESKey` is 43 base64 chars **without** padding; adapter appends `=` and decodes with Indifferent padding + `allow_trailing_bits` (the 43rd char's last 2 bits are not payload). Result must be exactly 32 bytes (`decode_aes_key`, `wecom.rs:74`). +- WeCom uses **PKCS7 with block_size=32** (not 16); adapter decrypts AES-256-CBC with `NoPadding` and strips padding manually (pad value 1–32). Plaintext = `random(16) + msg_len(4 BE) + msg + corp_id`; inner corp_id must equal configured `CORP_ID` (`wecom.rs:149-182`). IV = first 16 key bytes (`wecom.rs:134`). +- Defense-in-depth: outer envelope `ToUserName` must equal `CORP_ID` (`wecom.rs:971`); `msg_signature` compared in constant time (`subtle::ConstantTimeEq`, `wecom.rs:122`); stale callbacks (>300s timestamp skew) rejected (`wecom.rs:947-953`); 30s TTL / 10k-entry dedupe cache on `MsgId` absorbs WeCom's ~5s retries (`wecom.rs:189-219`). +- `gettoken` requires `corpsecret` as a **query param** (protocol-mandated) — operators must redact query strings on `/cgi-bin/gettoken` in proxy logs; gateway never logs that URL (`wecom.rs:275-283`). + +### "Streaming" is recall + resend + +Because callback mode has no message-edit API, streaming is emulated: optional "⏳..." placeholder, debounce-buffer deltas into a `tokio::sync::watch` channel (default 3s), then recall the placeholder and send the consolidated final text via `flush_thinking`. This flickers, so it is **off by default** (`WECOM_STREAMING_ENABLED`). With it off, deltas are buffered silently and one message is sent when the debounce settles — no flicker, no recall. A 300s idle cap on the debounce task prevents an orphaned pending entry. + +### Inbound filtering is aggressive + +Only `text`, `image`, `file` msgtypes are forwarded (`wecom.rs:1022`); `voice` / `video` / `location` / `link` are dropped. Files must pass a text-extension/filename allowlist AND be valid UTF-8 — office/binary files are rejected (no doc parsing). Images are HTTPS-only, ≤10MB, downscaled to ≤1200px JPEG q75 (GIF passthrough). Placeholder prompts are injected for media: image → "Describe this image.", file → "User sent a file: {name}" (`wecom.rs:1030-1035`). + +### Findings log + +- 2026-07-04 (A) Self-built app receive doc enumerates **six** inbound types: text/image/voice/video/location/link — file is *not* listed there (yet delivered & handled by the adapter). `FromUserName` = member UserID, `MsgId` = 64-bit; no group/@mention/reaction/edit in this model. [https://developer.work.weixin.qq.com/document/path/90239] +- 2026-07-04 (A) `text` content capped at 2048 bytes (truncated); push quota per app→member 30/min, 1000/hr, per-app (account-cap×200)/day, excess silently dropped. msgtypes: text/image/voice/video/file/textcard/news/mpnews/markdown/miniprogram_notice/template_card. [https://developer.work.weixin.qq.com/document/path/90236] +- 2026-07-04 (A) No edit API; only recall (`/cgi-bin/message/recall`) within 24h on this app's own messages (delete, not edit; WeChat-plugin-end data can't be pulled back). [https://developer.work.weixin.qq.com/document/path/94867] +- 2026-07-04 (A) Media caps: image 10MB (JPG/PNG), voice 2MB/60s (AMR), video 10MB (MP4), file 20MB; min 5 bytes; temp media_id valid 3 days. [https://developer.work.weixin.qq.com/document/path/90253] +- 2026-07-04 (A) Callback auth = SHA1 msg_signature (sorted token/ts/nonce/encrypt) + AES-256-CBC via 43-char EncodingAESKey, PKCS7 block_size=32, IV = first 16 key bytes. [https://developer.work.weixin.qq.com/document/path/90238] +- 2026-07-04 (B) All section-2 `file:line` refs verified against the tree at `/tmp/openab-check`; corrected `send_text` / `fetch_media_with_retry` / `gate_incoming` line numbers and the mention-gating range. `WecomAdapter` is a standalone handler (does **not** impl `ChatAdapter`); trait defaults referenced are in `adapter.rs`. [PR: @TBD] + +*Sourcing: `(A)` intrinsic facts → official WeCom doc; `(B)` OpenAB decisions/findings → `file:line` (PR link `@TBD`).* From 6e3f8ef463c369d6d71be1806935566e8b02b86e Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:08 +0000 Subject: [PATCH 04/37] docs(platforms): docs/platforms/discord.md (schema v1) --- docs/platforms/discord.md | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/platforms/discord.md diff --git a/docs/platforms/discord.md b/docs/platforms/discord.md new file mode 100644 index 000000000..70d0b7a9c --- /dev/null +++ b/docs/platforms/discord.md @@ -0,0 +1,88 @@ +--- +platform: discord +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# Discord — platform notes + +Engineering-facing capability & quirks reference for the Discord adapter. For operator setup see `docs/discord.md`. Follows the schemas in [`README.md`](./README.md). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | WebSocket Gateway (persistent WSS `wss://gateway.discord.gg/`). The bot opens a persistent gateway connection and receives events; outbound actions use the REST HTTP API. | [Gateway](https://docs.discord.com/developers/topics/gateway) | +| inbound_auth | Gateway handshake via Identify (opcode 2) carrying the bot token + intents. REST calls use the `Authorization: Bot ` header. Gateway is a bot-initiated persistent socket, so there is no per-event inbound signature to verify (unlike webhook platforms). | [Gateway](https://docs.discord.com/developers/topics/gateway) · [Reference](https://docs.discord.com/developers/reference) | +| threads | native. ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12) — temporary sub-channels of a text/forum/announcement channel (types available in API v9+). Threads are themselves channels (own `channel_id`, `thread_metadata`, `parent_id`). | [Channel](https://docs.discord.com/developers/resources/channel) | +| slash_commands | supported. Application commands of type CHAT_INPUT (1), registered over HTTP: global `POST /applications/{id}/commands` or per-guild `POST /applications/{id}/guilds/{gid}/commands`. Invocations delivered as `INTERACTION_CREATE` with an interaction token for the response. | [Application Commands](https://docs.discord.com/developers/interactions/application-commands) | +| mentions | Bot detects being addressed via user mention `<@user_id>` (and legacy nick form `<@!id>`) or role mention `<@&role_id>` in `mention_roles`. Gateway also flags `mentions` / `mention_everyone`. | [Message](https://docs.discord.com/developers/resources/message) | +| emoji_reactions | Bot **add**: `PUT` Create Reaction; **remove**: Delete Own Reaction (own) or delete others' with MANAGE_MESSAGES. **Receives**: `MESSAGE_REACTION_ADD` / `MESSAGE_REACTION_REMOVE` (needs GUILD_MESSAGE_REACTIONS intent `1 << 10`; DIRECT_MESSAGE_REACTIONS for DMs). | [Message](https://docs.discord.com/developers/resources/message) · [Gateway](https://docs.discord.com/developers/topics/gateway) | +| edit_message | Yes — a bot may edit its own messages via Edit Message. | [Message](https://docs.discord.com/developers/resources/message) | +| delete_message | Own: always. Others': requires MANAGE_MESSAGES permission. | [Message](https://docs.discord.com/developers/resources/message) | +| rich_content | Markdown, embeds, and message components (buttons, string/select menus, action rows). | [Message](https://docs.discord.com/developers/resources/message) | +| attachments | Inbound & outbound arbitrary file types. Default upload cap **10 MiB per file** (raised by uploader Nitro status or server Boost tier). | [Reference](https://docs.discord.com/developers/reference) | +| message_length_limit | 2000 characters per message content. | [Channel](https://docs.discord.com/developers/resources/channel) | +| dm_support | Yes — 1:1 DM channels (private channels). | [Channel](https://docs.discord.com/developers/resources/channel) | +| group_model | Guild → channels (GUILD_TEXT, forum, announcement, voice) → threads. Plus DM / group-DM private channels. Threads are channels with a `parent_id`. | [Channel](https://docs.discord.com/developers/resources/channel) | +| group_sender_identity | Yes — stable per-user snowflake `author.id` on every message; not consent-gated. Requires the MESSAGE_CONTENT intent to also read message text. | [Message](https://docs.discord.com/developers/resources/message) · [Gateway](https://docs.discord.com/developers/topics/gateway) | +| send_model | push. No reply-window/TTL — a bot with channel access may send at any time via REST. Replies are opt-in via `message_reference{message_id}`. | [Message](https://docs.discord.com/developers/resources/message) | +| proactive_push | Yes — unsolicited sends allowed within permissions. Global cap **50 requests/sec/bot**; per-route buckets (`X-RateLimit-Bucket`); invalid-request cap 10,000/10min. | [Rate Limits](https://docs.discord.com/developers/topics/rate-limits) | +| bot_to_bot | Yes — the gateway delivers other bots' messages; the `author.bot` flag distinguishes them (the bot's own messages arrive too and must be self-filtered). | [Gateway](https://docs.discord.com/developers/topics/gateway) | +| typing_indicator | Supported — `POST /channels/{id}/typing` (Trigger Typing); inbound `TYPING_START` event. | [Gateway](https://docs.discord.com/developers/topics/gateway) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | `ChannelId::say`; `resolve_channel` prefers `thread_id` over `channel_id`; `message_limit()` = 2000. | `discord.rs:70`, `discord.rs:55`, `discord.rs:66` | +| message_split/chunking | implemented | Router reads `message_limit()` (2000) then splits: `split_delivery` handles directive/body, `format::split_message` chunks the body, mentions are propagated to each chunk. | `adapter.rs:686`, `adapter.rs:1105`, `adapter.rs:149` | +| streaming | implemented | Post-then-edit, not native. Discord uses the edit-loop: `use_streaming` returns true only when no other bot is present (`!other_bot_present`); the router consults it at dispatch. `uses_native_streaming` stays default false, so the trait's native stream methods only hit their edit-based fallbacks. | `discord.rs:136`, `adapter.rs:687`, `adapter.rs:361`, `adapter.rs:380` | +| reply/quote | implemented | `send_message_with_reply` sets `reference_message`; falls back to plain send on invalid id (parses to 0) or reply failure (unknown/cross-channel message). | `discord.rs:83` | +| edit_message | implemented | Native `EditMessage.content` (overrides the trait default that returns "edit_message not supported"). | `discord.rs:123`, `adapter.rs:330` | +| delete_message | implemented | Native `http.delete_message` (overrides trait default which edits to a zero-width space `\u{200b}`). | `discord.rs:114`, `adapter.rs:353` | +| emoji_reactions | implemented | `add_reaction` = `create_reaction`, `remove_reaction` = `delete_reaction_me`. Unicode emoji only. | `discord.rs:164`, `discord.rs:177` | +| threads/topics | implemented | `create_thread` builds a thread from the trigger message via serenity `create_thread_from_message` (1-day auto-archive); auto-thread on first channel message via `get_or_create_thread`. Not the gateway `create_topic` path — the native adapter creates threads directly. | `discord.rs:140`, `discord.rs:2725` | +| media_inbound | implemented | Attachments processed inline in the per-attachment loop: images encoded (`download_and_encode_image`), text files (≤1 MB total, ≤5 files), video passed as a URL block; non-image files warned to the user. | `discord.rs:850`, `discord.rs:911` | +| voice_stt | implemented | Audio attachments transcribed via `media::download_and_transcribe` when `stt_config.enabled`; transcript injected + echoed; 🎤 reaction when STT disabled. | `discord.rs:852`, `discord.rs:855`, `discord.rs:883` | +| trust_gate | implemented | Two layers: adapter-level channel/user allowlist (`allowed_channels`, `is_denied_user`) + shared L3 identity gate `router.gate_incoming` (humans only; bots bypass via `l3_gate_applies`). | `discord.rs:803`, `discord.rs:1048`, `discord.rs:2922` | +| deny_echo | partial | On a denied user the bot reacts 🚫 on the offending message and drops it — no text reply (Discord L3 denies drop silently apart from the reaction). | `discord.rs:803` | +| mention_gating | implemented | `AllowUsers` modes: Mentions (always require @), Involved (skip @ if bot owns/participated in thread), MultibotMentions (require @ when other bots present). DMs treated as an implicit mention. | `discord.rs:759`, `discord.rs:456` | +| slash_commands | implemented | Global commands registered on `ready` via `set_global_commands`: /models, /agents, /cancel, /cancel-all, /reset, /remind, /auth, /export-thread; dispatched via `interaction_create`. | `discord.rs:1334`, `discord.rs:1386`, `discord.rs:1423` | +| multibot | implemented | Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; `trusted_bot_ids` + @mention admits handoff regardless of `allow_bot_messages`. | `discord.rs:331`, `discord.rs:593`, `discord.rs:2947` | +| group_routing | implemented | Per-thread dispatch keyed by `dispatcher.key("discord", channel_id, sender_id)`; thread↔parent allowlist via `detect_thread`; ambient mode buffers passive-channel messages. | `discord.rs:1066`, `discord.rs:2901`, `discord.rs:530` | + +## 3. Platform quirks (`platform-quirks` v1) + +### Threads are channels +A Discord thread has its own `channel_id`; the adapter resolves outbound targets via `thread_id.unwrap_or(channel_id)` (`resolve_channel`, `discord.rs:55`). Thread identity is `thread_metadata.is_some()` — `parent_id` alone is NOT reliable (category children also carry `parent_id`), so `detect_thread` returns early unless `has_thread_metadata`, and only uses `parent_id` for the allowlist check (`discord.rs:2901`). + +### Self-echo and bot-loop control +The bot receives its own messages over the gateway and must self-filter (`msg.author.id == bot_id`, `discord.rs:443`). Because multiple bots can ping-pong, there are layered guards: a hard consecutive-bot cap (`MAX_CONSECUTIVE_BOT_TURNS = 1000`), a configurable soft per-thread `max_bot_turns` reset by any human message, and `BotTurnTracker`. Bot-turn counting deliberately runs *before* the self-check (`discord.rs:341`) so all bot messages count, but warning posts respect the channel allowlist + prior participation to avoid uninvolved bots spamming (`discord.rs:402`). + +### Multibot detection is irreversible & disk-cached +Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to `MultibotCache` on disk (survives restarts), since bot messages don't disappear (`discord.rs:331`, `discord.rs:227`). This flips streaming off (the edit-loop interferes across bots) and can require @mention under `MultibotMentions`. + +### Streaming is post-then-edit, not native +Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. `use_streaming` disables this whenever another bot is present, to avoid edit interference (`discord.rs:136`, #534). `uses_native_streaming` stays false (`adapter.rs:361`), so the trait's native stream methods only ever hit their edit-based fallbacks. + +### create_topic vs create_thread +The shared gateway layer has a `create_topic` command (`gateway.rs:513`) for gateway-protocol adapters. The native Discord adapter does NOT use it — it calls serenity `create_thread_from_message` directly (`discord.rs:148`) and auto-creates a thread for top-level channel messages via `get_or_create_thread`. + +### Attachment handling caps +Text-file attachments are bounded independently of Discord's own 10 MiB upload cap: 1 MB total across all text files (`TEXT_TOTAL_CAP`) and max 5 files per message (`TEXT_FILE_COUNT_CAP`), enforced with a Discord-reported-size pre-check before download (`discord.rs:847`, `discord.rs:887`). Image URLs from Discord expire ~24h, which is surfaced to the agent in the injected block (`discord.rs:925`). + +### DMs +DM channels can't hold threads, so DMs reuse the DM channel directly and are treated as an implicit @mention; gated only by `allow_dm` + user allowlist (`should_process_dm` / `should_skip_thread_creation`, `discord.rs:680`, `discord.rs:967`). + +### Findings log +- 2026-07-04 (A) Default file-upload cap is 10 MiB/file (raised by Nitro/Boost); the adapter's own text-attachment caps (1 MB total / 5 files) are stricter. [Reference](https://docs.discord.com/developers/reference) +- 2026-07-04 (A) Global REST rate limit is 50 req/sec/bot plus per-route buckets (`X-RateLimit-Bucket`); invalid requests capped at 10,000/10min — relevant to proactive-push and streaming edit-loop cadence. [Rate Limits](https://docs.discord.com/developers/topics/rate-limits) +- 2026-07-04 (A) Message content hard limit is 2000 chars, matching `DiscordAdapter::message_limit()`; the router chunks longer replies. [Channel](https://docs.discord.com/developers/resources/channel) +- 2026-07-04 (A) Thread channel types are ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12), API v9+; identified by `thread_metadata`, not `parent_id` (category children also carry `parent_id`); `detect_thread` follows this. [Channel](https://docs.discord.com/developers/resources/channel) +- 2026-07-04 (A) Transport is a bot-initiated persistent WebSocket Gateway (WSS) authenticated by Identify(op 2) + bot token + intents — no per-event inbound signature to verify (unlike webhook platforms). [Gateway](https://docs.discord.com/developers/topics/gateway) +- 2026-07-04 (B) Section-2 ref audit: chunking lives at `adapter.rs:686`/`:1105` (not the trait def near `:306`); slash-command registration is `set_global_commands` at `discord.rs:1386`; `is_denied_user` at `discord.rs:2922`, trusted-bot bypass at `discord.rs:2947`. Corrected stale line refs. [PR #TBD] From 633a887f51db1e9152ac93f6586149fabb4f5c08 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:10 +0000 Subject: [PATCH 05/37] docs(platforms): docs/platforms/_template.md (schema v1) --- docs/platforms/_template.md | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/platforms/_template.md diff --git a/docs/platforms/_template.md b/docs/platforms/_template.md new file mode 100644 index 000000000..882a7db89 --- /dev/null +++ b/docs/platforms/_template.md @@ -0,0 +1,65 @@ +--- +platform: +maintainer: "@" +last_verified: +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# — platform notes + +Engineering-facing capability & quirks reference for the adapter. For operator setup see `docs/.md`. Follows the schemas in [`README.md`](./README.md). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | | | +| inbound_auth | | | +| threads | | | +| slash_commands | | | +| mentions | | | +| emoji_reactions | | | +| edit_message | | | +| delete_message | | | +| rich_content | | | +| attachments | | | +| message_length_limit | | | +| dm_support | | | +| group_model | | | +| group_sender_identity | | | +| send_model | | | +| proactive_push | | | +| bot_to_bot | | | +| typing_indicator | | | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | | | | +| message_split/chunking | | | | +| streaming | | | | +| reply/quote | | | | +| edit_message | | | | +| delete_message | | | | +| emoji_reactions | | | | +| threads/topics | | | | +| media_inbound | | | | +| voice_stt | | | | +| trust_gate | | | | +| deny_echo | | | | +| mention_gating | | | | +| slash_commands | | | | +| multibot | | | | +| group_routing | | | | + +## 3. Platform quirks (`platform-quirks` v1) + +### + + +### Findings log +- YYYY-MM-DD (A|B) . [source] From 93848eeffacc5c20caeef220b5e9e40aaba79807 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:11 +0000 Subject: [PATCH 06/37] docs(platforms): docs/platforms/telegram.md (schema v1) --- docs/platforms/telegram.md | 92 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/platforms/telegram.md diff --git a/docs/platforms/telegram.md b/docs/platforms/telegram.md new file mode 100644 index 000000000..88bfaa235 --- /dev/null +++ b/docs/platforms/telegram.md @@ -0,0 +1,92 @@ +--- +platform: telegram +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# Telegram — platform notes + +Engineering-facing capability & quirks reference for the Telegram adapter. For operator setup see `docs/telegram.md`. Follows the schemas in [`README.md`](./README.md). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | webhook (JSON POST of `Update`); long-poll `getUpdates` also exists but OpenAB uses webhook. | [Bot API — getting updates](https://core.telegram.org/bots/api#getting-updates) | +| inbound_auth | L1 only: `X-Telegram-Bot-Api-Secret-Token` header (opaque shared secret set on `setWebhook`) + optional source-IP allowlist (`149.154.160.0/20`, `91.108.4.0/22`). No HMAC/body signature. | [Webhooks](https://core.telegram.org/bots/webhooks), [setWebhook](https://core.telegram.org/bots/api#setwebhook) | +| threads | native (forum topics only): supergroups with topics enabled carry `message_thread_id`; created via `createForumTopic`. Non-forum chats have no threading (reply-to quoting exists separately). | [createForumTopic](https://core.telegram.org/bots/api#createforumtopic) | +| slash_commands | supported: `/command` text arrives as a `bot_command` entity; discoverable list registered via `setMyCommands`. Command names ≤32 chars, Latin letters/digits/underscores. Delivery is just message text — the bot parses it. | [setMyCommands](https://core.telegram.org/bots/api#setmycommands), [commands](https://core.telegram.org/bots/features#commands) | +| mentions | two entity types: `mention` (`@username`) and `text_mention` (users without a username, carries a `User`). With privacy mode ON, a bot in a group only receives: commands meant for it (`/cmd@bot`), general commands if it was the last bot to message, replies to its messages, inline messages via it, and all service messages. | [MessageEntity](https://core.telegram.org/bots/api#messageentity), [privacy mode](https://core.telegram.org/bots/features#privacy-mode) | +| emoji_reactions | bot **add/set**: yes via `setMessageReaction` (replaces the bot's whole reaction set; non-premium is limited to one emoji from the allowed set). **remove**: yes (set to empty). **receive**: `message_reaction` updates exist but — quoting the API — "The bot must be an administrator in the chat and must explicitly specify `message_reaction` in the list of `allowed_updates` to receive these updates. The update isn't received for reactions set by bots." | [setMessageReaction](https://core.telegram.org/bots/api#setmessagereaction), [Update.message_reaction](https://core.telegram.org/bots/api#update) | +| edit_message | yes: `editMessageText` (also `editMessageCaption`/`editMessageMedia`) on the bot's own messages. | [editMessageText](https://core.telegram.org/bots/api#editmessagetext) | +| delete_message | `deleteMessage`: a bot can delete its own outgoing messages; can delete others' only with admin `can_delete_messages`. Constraint: a message can only be deleted if it was sent less than **48 hours** ago (with narrow exceptions, e.g. service messages / a bot's own messages / channel/anonymous-admin cases). | [deleteMessage](https://core.telegram.org/bots/api#deletemessage) | +| rich_content | Markdown/MarkdownV2 + HTML `parse_mode`; inline keyboards / reply keyboards / callback buttons. **Bot API 10.1 (2026-06-11)** additionally added `sendRichMessage` + `InputRichMessage` for GFM-style rich blocks (tables, headings, syntax-highlighted code) — see quirks. No legacy "card" object beyond keyboards. | [formatting](https://core.telegram.org/bots/api#formatting-options), [api-changelog (10.1)](https://core.telegram.org/bots/api-changelog) | +| attachments | inbound: photo, document, voice, audio, video, etc. outbound: photo/document/etc. **Download** via `getFile` is capped at **20 MB** on the cloud Bot API (up to 2 GB only with a self-hosted local Bot API server). Send caps: photos ~10 MB, other files 50 MB. | [getFile](https://core.telegram.org/bots/api#getfile), [sendDocument](https://core.telegram.org/bots/api#senddocument) | +| message_length_limit | `sendMessage` text: **1–4096 UTF-8 chars**; captions 1024. Longer plain text must be chunked. (Rich messages via `sendRichMessage` accept larger content — see quirks; exact ceiling unverified from truncated docs.) | [sendMessage](https://core.telegram.org/bots/api#sendmessage) | +| dm_support | yes (1:1 private chat); the user must `/start` the bot first — bots cannot initiate a private chat. | [Bot FAQ](https://core.telegram.org/bots/faq) | +| group_model | private (DM), group, supergroup (optionally forum = topics), channel. `chat.type` distinguishes them. | [Chat](https://core.telegram.org/bots/api#chat) | +| group_sender_identity | yes: stable numeric `from.id` present on group messages (not consent-gated), unless sent on behalf of a chat (`sender_chat`). Privacy mode limits *which* messages the bot sees, not the identity of the ones it does. | [Message](https://core.telegram.org/bots/api#message) | +| send_model | push model: the bot may send to any chat it shares with the user at any time (no reply-window/TTL). Reply/quote via `reply_parameters`. Rate-limited (see proactive_push). | [sendMessage](https://core.telegram.org/bots/api#sendmessage) | +| proactive_push | allowed to any user who has started the bot / any group it's in. Rate limits (per FAQ): "avoid sending more than one message per second" per chat; bulk broadcast "not able to broadcast more than about 30 messages per second" overall; "not be able to send more than 20 messages per minute" in a group. No reply-window gating. | [Broadcasting FAQ](https://core.telegram.org/bots/faq) | +| bot_to_bot | per FAQ, "Bots will not be able to see messages from other bots regardless of mode." `is_bot` is present on `from` for the rare cases forwarded/quoted. | [Bot FAQ](https://core.telegram.org/bots/faq) | +| typing_indicator | yes: `sendChatAction` (e.g. `typing`), auto-clears after ~5s or on next message. Not used by the OpenAB adapter. | [sendChatAction](https://core.telegram.org/bots/api#sendchataction) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +Telegram runs through the **gateway** path (webhook → `GatewayEvent`; `GatewayReply` → `handle_reply` command dispatch), not a `ChatAdapter` trait impl. So the `ChatAdapter` default-impl semantics (edit/delete/stream) do not directly apply; the gateway reply handler dispatches by `reply.command`. Note: the `MAX_DOWNLOAD` constants used below live in the **gateway** crate (`crates/openab-gateway/src/media.rs:13-15` → IMAGE 10 MB, FILE 20 MB, AUDIO 20 MB), while STT lives in **core**. + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | `sendMessage` with `parse_mode: Markdown`; on a Markdown parse error (`is_markdown_parse_error`) retries once as plain text. | `crates/openab-gateway/src/adapters/telegram.rs:604`, `:621` | +| message_split/chunking | implemented | `chunk_text` splits at 4096 chars, preferring newline boundaries; hard-splits a single over-limit line char-by-char. (Adapter-local, not the trait's `split_delivery`.) | `crates/openab-gateway/src/adapters/telegram.rs:602`, `:237` | +| streaming | partial | No legacy streaming API; streaming maps onto `editMessageText` when `reply_to` is a real message_id. When `reply_to == "draft"` and `rich_messages` is on, it calls `sendRichMessageDraft` (skips updates <30 chars, truncates at 32768 via `floor_char_boundary`). That draft fn `send_rich_message_draft` is `#[allow(dead_code)]` — "Wired but unused until gateway streaming infrastructure integrates" — so the draft path is not yet driven by gateway streaming. | `crates/openab-gateway/src/adapters/telegram.rs:470`, `:475`, `:485`, `:379` | +| reply/quote | partial | `GatewayReply` carries a `quote_message_id`, but the Telegram send path does **not** attach `reply_to_message_id`/`reply_parameters`; outbound only sets `message_thread_id` (forum-topic routing). So agent quote-replies aren't rendered as native quotes. | `crates/openab-gateway/src/adapters/telegram.rs:610`; `crates/openab-core/src/gateway.rs:516` | +| edit_message | implemented | `command == "edit_message"` → `editMessageText` (real msg_id) or, for a `"draft"` ref with `rich_messages` on, `sendRichMessageDraft`. | `crates/openab-gateway/src/adapters/telegram.rs:470`, `:491` | +| delete_message | not-implemented | No `deleteMessage` call anywhere in the adapter and no delete command handled in `handle_reply` (the command dispatch chain covers only create_topic / edit_message / reactions / send). Telegram supports it natively; simply not wired. The trait's default `delete_message` returns an unsupported error. | `crates/openab-gateway/src/adapters/telegram.rs:405`; `crates/openab-core/src/adapter.rs:353` | +| emoji_reactions | workaround | `add_reaction`/`remove_reaction` → `setMessageReaction`. Because non-premium chats allow only ONE reaction, the adapter keeps a local `reaction_state` map and always sends the full replacing set; the mood-face emojis (😊😎🫡🤓😏✌️💪🦾) are dropped so the terminal 👍 "done" marker isn't clobbered, and 🆗→👍 is remapped. | `crates/openab-gateway/src/adapters/telegram.rs:506`, `:511`, `:516`, `:527` | +| threads/topics | implemented | `command == "create_topic"` → `createForumTopic`, returns `message_thread_id` in a `GatewayResponse`. The trait's `create_thread` issues that command with a 5s timeout and falls back to the same channel on failure/timeout; core ingress only creates a topic for supergroups with no existing `thread_id`. | `crates/openab-gateway/src/adapters/telegram.rs:414`, `:427`; `crates/openab-core/src/gateway.rs:490`, `:530`, `:1036` | +| media_inbound | implemented | photo (largest by `width*height`), document (text-only, extension-checked + UTF-8 validated), voice, audio downloaded via `getFile`; size-gated against `IMAGE/AUDIO/FILE_MAX_DOWNLOAD` (both Content-Length pre-check and post-read); images resized/compressed; non-text or binary docs rejected with a reason. | `crates/openab-gateway/src/adapters/telegram.rs:160`, `:658`, `:802`, `:809`, `:898` | +| voice_stt | partial | The adapter only downloads voice/audio as an `audio` attachment (`MediaKind::Audio`, `download_telegram_media`); it does no transcription. STT happens downstream in core: `gateway.rs:955` (batched) / `:1340` (per-message) call `download_and_transcribe` when `stt_config.enabled`, injecting a `[Voice message transcript]:` block. | `crates/openab-gateway/src/adapters/telegram.rs:170`; `crates/openab-core/src/gateway.rs:955`, `:1340`; `crates/openab-core/src/media.rs:302` | +| trust_gate | partial | Adapter enforces L1 only: `secret_token` header + optional Telegram source-subnet check (`telegram_trusted_source_only`; IP extraction is phase-1 "observe", trusting CF/X-Real-IP then the spoofable leftmost XFF). L2 scope / L3 identity allowlists are enforced by the shared core ingress trust gate, not the adapter. | `crates/openab-gateway/src/adapters/telegram.rs:109`, `:128`, `:133`, `:271`; `crates/openab-core/src/gateway.rs:1190` | +| deny_echo | implemented | Not in the adapter — handled by core ingress: an L3 `DenyIdentity` decision echoes the sender their ID ("Your ID: … Ask the admin to add it to allowed_users"), throttled to one echo per (platform, sender) per `ECHO_WINDOW` (300s). `DenyScope` is a silent drop. | `crates/openab-core/src/gateway.rs:1201`, `:1220`, `:1150`, `:1228` | +| mention_gating | implemented | In groups/supergroups without a thread, core `should_skip_event` requires an `@bot_username` mention; in-thread events bypass the gate. The adapter extracts `mention` entities into `event.mentions`. | `crates/openab-core/src/gateway.rs:72`, `:76`; `crates/openab-gateway/src/adapters/telegram.rs:195` | +| slash_commands | implemented | `/reset` and `/cancel` handled in core gateway (both the batched consumer path and the per-message path). The adapter has no per-command parsing beyond forwarding text. | `crates/openab-core/src/gateway.rs:1004`, `:1017`, `:1376`, `:1389` | +| multibot | implemented | `should_skip_event` drops other bots' events unless the sender id is in `trusted_bot_ids`; the adapter forwards `is_bot` from `from`. (Telegram rarely delivers other bots' messages anyway.) | `crates/openab-core/src/gateway.rs:58`; `crates/openab-gateway/src/adapters/telegram.rs:217` | +| group_routing | implemented | Session keyed by `chat.id` + optional `message_thread_id`; `compute_draft_id` derives a stable per-(chat,thread) draft id to avoid forum-topic collisions. | `crates/openab-gateway/src/adapters/telegram.rs:206`, `:339`, `:484` | + +## 3. Platform quirks (`platform-quirks` v1) + +### "Rich Message" API is real (Bot API 10.1) — but the adapter path is flag-gated and partly dead-code +The adapter's `sendRichMessage` / `sendRichMessageDraft` / `InputRichMessage.markdown` calls correspond to methods **genuinely added in Bot API 10.1 (2026-06-11)** ([api-changelog](https://core.telegram.org/bots/api-changelog)): "Added the method `sendRichMessage`…", "Added the method `sendRichMessageDraft`, allowing bots to stream partial rich messages", "Added the class `InputRichMessage`…". This corrects an earlier assumption that the path was fictional. Caveats that remain code-side, not API-side: +- The path is gated behind the adapter's `rich_messages` flag and falls back to legacy chunked `sendMessage` on **any** error (`is_complex_markdown` picks headings/GFM tables/over-4096-char text as candidates; code blocks are deliberately routed to legacy `sendMessage` to keep syntax highlighting). +- `send_rich_message_draft` is `#[allow(dead_code)]` and not yet driven by gateway streaming. +- The **exact 32768-char limit** and the precise `InputRichMessage` field names (`markdown` vs `html`) the adapter assumes could not be confirmed from the official page (method-body sections were truncated on fetch); anyone enabling this should confirm those against their Bot API server / the full 10.1 docs before relying on the 32768 truncation and the html/markdown branch. + +### Reactions are single-slot, non-additive +Telegram non-premium chats allow only one reaction per message. The adapter therefore maintains `reaction_state` and always PUTs the full replacing set via `setMessageReaction`; multi-emoji "mood" reactions used on Discord are deliberately dropped so the terminal 👍 isn't clobbered. Consequence: OpenAB's add/remove reaction semantics are emulated, not literal. Separately, inbound reaction events (`message_reaction`) require the bot be a chat admin + opt in via `allowed_updates`, and are **never** delivered for reactions set by bots — the adapter does not subscribe to them. + +### Auth is L1-only and IP extraction is phase-1 +Only `secret_token` + an optional source-subnet check gate the webhook. Subnet enforcement is opt-in (`telegram_trusted_source_only`), and IP extraction trusts `CF-Connecting-IP` / `X-Real-IP` then falls back to the spoofable leftmost `X-Forwarded-For` — a documented phase-2 gap. Real identity/scope trust lives in core ingress (L2 scope / L3 identity), and the deny-echo lives there too. + +### 20 MB inbound download ceiling +`getFile` on the cloud Bot API can't serve files >20 MB; large media will fail to download regardless of OpenAB's own `*_MAX_DOWNLOAD` limits (10/20/20 MB). A self-hosted local Bot API server is required for up to 2 GB. Note also that STT downstream imposes its own 25 MB (Whisper) cap in core (`media.rs:311`). + +### Outbound quotes aren't native +`GatewayReply.quote_message_id` exists, but the Telegram send path never sets `reply_to_message_id`/`reply_parameters` — it only sets `message_thread_id`. Agent quote-replies therefore land in the right topic but aren't rendered as native Telegram quotes. + +### Findings log +- 2026-07-04 (A) Bot API 10.1 (2026-06-11) added `sendRichMessage`, `sendRichMessageDraft`, and `InputRichMessage` — the adapter's "rich" path targets a REAL API, not a fictional one (corrects prior draft). Exact 32768 limit / field names still unverified (docs truncated on fetch). [[api-changelog](https://core.telegram.org/bots/api-changelog)] +- 2026-07-04 (A) Telegram cloud `getFile` caps downloads at 20 MB; up to 2 GB needs a self-hosted local Bot API server. [[getFile](https://core.telegram.org/bots/api#getfile)] +- 2026-07-04 (A) `deleteMessage` generally only works within 48h for a bot's outgoing messages; deleting others' needs admin `can_delete_messages`. [[deleteMessage](https://core.telegram.org/bots/api#deletemessage)] +- 2026-07-04 (A) `message_reaction` updates require the bot be a chat admin + opt-in via `allowed_updates`, and "The update isn't received for reactions set by bots." [[Update](https://core.telegram.org/bots/api#update)] +- 2026-07-04 (A) `sendMessage` text limit is 1–4096 UTF-8 chars; adapter chunks at 4096. [[sendMessage](https://core.telegram.org/bots/api#sendmessage)] +- 2026-07-04 (A) Broadcasting limits: ~1 msg/s per chat, ~30 msg/s overall bulk, 20 msg/min per group. [[FAQ](https://core.telegram.org/bots/faq)] +- 2026-07-04 (A) With privacy mode ON, a group bot receives only `/cmd@bot` commands, general commands if it messaged last, replies to it, inline messages via it, and all service messages. [[privacy mode](https://core.telegram.org/bots/features#privacy-mode)] +- 2026-07-04 (B) `delete_message` is unimplemented in the Telegram adapter despite native support — no `deleteMessage` call or command handler; trait default returns unsupported. [`crates/openab-gateway/src/adapters/telegram.rs:405`; `crates/openab-core/src/adapter.rs:353`] +- 2026-07-04 (B) Outbound send sets `message_thread_id` for topic routing but never `reply_to_message_id`, so agent quote-replies aren't native quotes. [`crates/openab-gateway/src/adapters/telegram.rs:610`] +- 2026-07-04 (B) MAX_DOWNLOAD constants (IMAGE 10 / FILE 20 / AUDIO 20 MB) live in the gateway crate, not core; core adds a separate 25 MB Whisper STT cap. [`crates/openab-gateway/src/media.rs:13-15`; `crates/openab-core/src/media.rs:311`] +- 2026-07-04 (B) Voice/audio STT is not done in the adapter; core `gateway.rs:955`/`:1340` transcribe `audio` attachments when `stt_config.enabled`. [`crates/openab-core/src/gateway.rs:955`, `:1340`] \ No newline at end of file From 6e364c1704c720147e063f2d184e656293834be0 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:13 +0000 Subject: [PATCH 07/37] docs(platforms): docs/platforms/googlechat.md (schema v1) --- docs/platforms/googlechat.md | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/platforms/googlechat.md diff --git a/docs/platforms/googlechat.md b/docs/platforms/googlechat.md new file mode 100644 index 000000000..219ca5289 --- /dev/null +++ b/docs/platforms/googlechat.md @@ -0,0 +1,91 @@ +--- +platform: googlechat +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# Google Chat — platform notes + +Engineering-facing capability & quirks reference for the Google Chat adapter. For operator setup see `docs/googlechat.md`. Follows the schemas in [`README.md`](./README.md). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | webhook (HTTP endpoint URL) or Pub/Sub; adapter accepts both envelope shapes. Google Chat calls the app on interaction events; app must respond within ~30 s. | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | +| inbound_auth | Bearer ID-token (JWT, RS256) in `Authorization` header, signed by Google, verified against Google JWKS (`https://www.googleapis.com/oauth2/v3/certs`). `iss=https://accounts.google.com`; `aud` verified against the app's configured audience (project number for HTTP-endpoint apps, or app URL); `email` claim must equal `chat@system.gserviceaccount.com`. | [Verify requests are from Google Chat](https://developers.google.com/workspace/chat/receive-respond-interactions) | +| threads | native. Spaces are either threaded or flat ("in-line"); reply targets a `thread.name`/`threadKey` with `messageReplyOption` (adapter uses `REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`). Thread field is ignored in un-threaded spaces. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | +| slash_commands | supported. Registered in Cloud console (APIs & Services → Google Chat API → Configuration → Add a command; `/name`, commandId **1–1000**). Delivered as a `MESSAGE` event carrying `message.slashCommand` / `message.annotation.slashCommand` with the matching `commandId`. | [Slash commands](https://developers.google.com/workspace/chat/commands) | +| mentions | In spaces the app is only invoked when @mentioned (or via slash command / app-added); mention text is stripped into `argumentText`. In DMs every message reaches the app. Mentions encoded as `` (`` for @all). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) · [Format messages](https://developers.google.com/workspace/chat/format-messages) | +| emoji_reactions | Reaction resource + create/delete/list methods exist, BUT `reactions.create`/`reactions.delete` **require USER authentication** — the REST reference states "Requires user authentication" with scopes `chat.messages.reactions.create` / `chat.messages.reactions` / `chat.messages`; **no app-auth alternative is listed**. A `chat.bot` app therefore **cannot add or remove** reactions. Apps can still receive `REACTION_ADDED`/`REACTION_REMOVED` interaction events. | [reactions.create (REST ref)](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create) · [Add a reaction](https://developers.google.com/workspace/chat/create-reactions) | +| edit_message | Yes — `spaces.messages.patch` with `updateMask=text`; with app auth an app can update **only messages it created**. Scope `chat.bot`. | [messages.patch](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch) | +| delete_message | Own only — `spaces.messages.delete`; with app auth an app can delete **only messages it created** (not user/other-app messages). Scope `chat.bot`. | [messages.delete](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/delete) | +| rich_content | Native text formatting (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, ```` ```block``` ````, `` links, `` mentions, bullet/quote). Cards v2 + interactive widgets/buttons/dialogs (app auth). | [Format messages](https://developers.google.com/workspace/chat/format-messages) | +| attachments | Inbound: user-uploaded attachments downloaded via Media API (`UPLOADED_CONTENT`; Drive-sourced needs the Drive API, not handled). Upload limit **200 MB**; some file types are blocked. Outbound app uploads also up to 200 MB (a message with an attachment can't also carry accessory widgets). | [Upload media as attachment](https://developers.google.com/workspace/chat/upload-media-attachments) | +| message_length_limit | Max message payload (text + cards) **32,000 bytes**; larger must be split into multiple messages. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | +| dm_support | Yes — 1:1 DM space between a user and the app (space type DM). App receives all DM messages (no mention required). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | +| group_model | "Spaces": DM (1:1), group chat (unnamed multi-person), and named spaces/rooms. Spaces are threaded or flat. Routing keyed on `space.name` (e.g. `spaces/AAAA`). | [Create messages](https://developers.google.com/workspace/chat/create-messages) | +| group_sender_identity | Yes, stable & non-consent-gated — every message event carries `sender` = user resource name `users/{id}` + `displayName` + `type` (HUMAN/BOT). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | +| send_model | Push (REST `spaces.messages.create`) with app auth; no reply-token/TTL window. Interaction responses may also be returned synchronously in the webhook response body. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | +| proactive_push | Yes, app can post proactively to spaces it belongs to. Quota: **3,000 message writes/min per project** (create+patch+delete combined), and **1 write/sec per space** (shared across all apps in the space; 10/sec only during data import). 429 `Too many requests` on overflow → truncated exponential backoff. | [Usage limits](https://developers.google.com/workspace/chat/limits) | +| bot_to_bot | Not delivered — the `MESSAGE` interaction event is defined as "A user messages a Chat app"; other apps'/bots' messages are not surfaced to a Chat app. (No doc positively states delivery; treat as unsupported. Adapter also drops `sender.type == "BOT"` inbound.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | +| typing_indicator | Not supported — Google Chat exposes no typing/composing API for apps; none of the documented app-facing interaction events or send surfaces include a typing signal. (Verified negative against the interactions surface, which enumerates the full set of app capabilities.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | `POST {space}/messages` with markdown→gchat conversion; returns message `name`. | `crates/openab-gateway/src/adapters/googlechat.rs:1060` | +| message_split/chunking | implemented | Splits at `GOOGLE_CHAT_MESSAGE_LIMIT = 4096` (conservative vs the 32 KB API cap) on newline/space boundaries, UTF-8-safe; each chunk a separate message; first message id / first error acked. | `crates/openab-gateway/src/adapters/googlechat.rs:14`, `:371`, `:1112` | +| streaming | partial | No native streaming API. Gateway adapter leaves `uses_native_streaming=false`, so core streams via the post-then-`edit_message` loop; each edit sends the full accumulated text as a `patch` call. | adapter `edit_message` `crates/openab-gateway/src/adapters/googlechat.rs:300`; trait defaults `crates/openab-core/src/adapter.rs:361,380` | +| reply/quote | partial | No dedicated quote/reply-to. `send_message_with_reply` uses the trait default → plain `send_message`; thread continuity is via `thread_id`, not a per-message quote. | trait default `crates/openab-core/src/adapter.rs:336`; adapter send `crates/openab-gateway/src/adapters/googlechat.rs:1075` | +| edit_message | implemented | `PATCH {message_name}?updateMask=text`; core gates the streaming edit rate on the ack. Own messages only (platform rule). | adapter `crates/openab-gateway/src/adapters/googlechat.rs:300`; core dispatch (`command:"edit_message"`) `crates/openab-core/src/gateway.rs:623` | +| delete_message | not-implemented | GatewayAdapter overrides `delete_message` to emit a fire-and-forget `command:"delete_message"` (`gateway.rs:679`) instead of the trait default (edit-to-zero-width, `adapter.rs:353`). The googlechat adapter does **not** match `delete_message` in `handle_reply` (only `add_reaction`/`remove_reaction`/`create_topic`/`edit_message`), so it falls through to the send path with empty text → hits the empty-message short-circuit and sends nothing. Net: delete is a no-op on Google Chat. | adapter cmd match `crates/openab-gateway/src/adapters/googlechat.rs:335`, empty-msg short-circuit `:374`; core override `crates/openab-core/src/gateway.rs:679`; trait default `crates/openab-core/src/adapter.rs:353` | +| emoji_reactions | not-implemented | Core dispatches `add_reaction`/`remove_reaction` commands (`gateway.rs:541,563`) but the adapter early-returns on them (`match … => return`). Deliberate: Google Chat reactions require USER auth; the app (`chat.bot`) cannot react. Status/thinking reactions therefore never render on Google Chat. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:335`; core `crates/openab-core/src/gateway.rs:541,563` | +| threads/topics | partial | Inbound `thread.name` is captured (`:551`) and used to keep replies in-thread (send sets `thread.name` + `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`, `:1075`). `create_thread`→`create_topic` command (`gateway.rs:513`) is early-returned by the adapter, so explicit topic creation is a no-op and core falls back to the same channel (`gateway.rs:530`). | adapter `crates/openab-gateway/src/adapters/googlechat.rs:335`, `:551`, `:1075`; core `crates/openab-core/src/gateway.rs:490,513,530` | +| media_inbound | implemented | Async download via Media API after the 200 response: images (resize longest side ≤1200px, JPEG q75, ≤10 MB; GIF passthrough); text-like files (extension whitelist, ≤512 KB each, ≤5 files / ≤1 MB aggregate); audio (≤25 MB, stored raw). Drive-sourced & video skipped. | `crates/openab-gateway/src/adapters/googlechat.rs:1153`, `:1196`, `:1239`, `:1347`, `:1455` | +| voice_stt | partial | Adapter downloads audio and emits it as an `audio` attachment with real MIME; STT happens in core only if `stt_config.enabled`, else forwarded as a "transcription failed" note. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:1455`; core STT `crates/openab-core/src/gateway.rs:1340` | +| trust_gate | implemented | Two layers: (1) webhook JWT verify — RS256 via Google JWKS + `email==chat@system.gserviceaccount.com` (`googlechat.rs:150,218`); (2) shared ingress `router.gate_incoming` L2 scope + L3 identity in `process_gateway_event`. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:150,218`; core `crates/openab-core/src/gateway.rs:1196` | +| deny_echo | implemented | `DenyIdentity` → throttled request-access echo ("Your ID: …") via `adapter.send_message`; `DenyScope` → silent drop. Platform-agnostic; delivered through the normal Google Chat send path. | `crates/openab-core/src/gateway.rs:1201` | +| mention_gating | implemented (platform-native) | Google Chat itself only delivers space messages when the app is @mentioned. Core `should_skip_event` mention-gating only fires for `channel_type == "group"`/`"supergroup"` (`gateway.rs:72-81`); Google Chat spaces surface as `ROOM`/`SPACE`/`DM`, so the core `@mention` string check never triggers — gating is enforced by the platform, not core. | core `crates/openab-core/src/gateway.rs:56,72`; adapter space_type `crates/openab-gateway/src/adapters/googlechat.rs:547` | +| slash_commands | implemented (core-side) | `/reset`, `/cancel`, `/model(s)`/`/agents` intercepted in `process_gateway_event` before dispatch; responses sent via `adapter.send_message`. Not wired to Google Chat's native slash-command registration — plain-text commands. | `crates/openab-core/src/gateway.rs:1376`, `:1389`, `:1398` | +| multibot | partial | Bot senders are dropped inbound (`sender.user_type == "BOT"` → skip, `googlechat.rs:532`); core's multibot/other-bot streaming suppression can't observe other bots since Chat doesn't deliver their messages. `use_streaming` ignores `other_bot_present` for gateway adapters. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:532`; core `crates/openab-core/src/gateway.rs:701` | +| group_routing | implemented | Session/route keyed on `space.name` + optional `thread_id`; ChannelInfo carries `id`=space, `channel_type`=space type, `thread_id`. | `crates/openab-gateway/src/adapters/googlechat.rs:697` | + +## 3. Platform quirks (`platform-quirks` v1) + +### Auth asymmetry: two independent credential paths +Inbound webhooks are authenticated by verifying Google's ID-token (`email==chat@system.gserviceaccount.com`, RS256 via JWKS, `iss=accounts.google.com`, audience-checked). Outbound API calls use a *separate* service-account → OAuth2 JWT-bearer exchange (`scope=https://www.googleapis.com/auth/chat.bot`), cached with a 300 s refresh margin. Missing outbound creds degrade to a logged dry-run that still acks failure to core. (`googlechat.rs:150,218,773,831,349`) + +### Reactions are structurally impossible for the bot +The single biggest divergence from other adapters: Google Chat's reaction API is **user-auth only** (the `reactions.create` REST reference explicitly lists only user-auth scopes). A `chat.bot` app cannot add/remove reactions at all, so OpenAB's status-reaction UX (👀 queued / 🤔 thinking / tool emojis / mood face) is silently dropped. The adapter deliberately early-returns on `add_reaction`/`remove_reaction`/`create_topic` rather than issuing doomed API calls. Consider a message-edit-based status line if a progress indicator is needed here. (`googlechat.rs:335`) + +### Markdown must be raw, once +`markdown_to_gchat` converts CommonMark → Chat syntax (`**b**`→`*b*`, `*i*`→`_i_`, `~~s~~`→`~s~`, `[t](u)`→``, headings→bold, code fences/inline code passthrough). It is applied by *both* `send_message` and `edit_message`; passing already-converted text double-converts (e.g. `*bold*` re-parsed as `*italic*`). Core must always emit raw markdown on streaming edits. (`googlechat.rs:860`, inline conversion `:907`) + +### 30-second webhook deadline vs. attachment downloads +Attachment-bearing messages spawn a background (panic-guarded) task and return `{}` immediately so the webhook meets Chat's ~30 s deadline; the GatewayEvent is emitted only after downloads finish. Text-only messages emit synchronously. Event dropped if both text and all attachments are empty/failed. (`googlechat.rs:577`) + +### Two webhook envelope shapes +Google Chat delivers either top-level (`message`/`user`/`space`) for HTTP-endpoint mode or wrapped under `chat.messagePayload` for Pub/Sub mode. The handler prefers the wrapped form and falls back to top-level. (`googlechat.rs:38,501`) + +### Chunk cap set below the API cap +Adapter chunks at 4096 chars while the API accepts 32,000 bytes — a conservative choice (matches the legacy client-visible limit) that produces more messages than strictly necessary for very long replies. (`googlechat.rs:14`) + +### Delete is a silent no-op (not even the edit fallback) +Unlike platforms where `delete_message` falls back to the trait's edit-to-zero-width, on Google Chat the `delete_message` command isn't matched in `handle_reply`, falls through to the send path with empty text, and hits the empty-message short-circuit — so nothing is sent and no edit occurs. Streaming-placeholder cleanup that relies on delete is therefore a no-op here. (`googlechat.rs:335,374`) + +### Findings log +- 2026-07-04 (A) `reactions.create`/`reactions.delete` require **user** auth (REST ref lists only `chat.messages.reactions*` / `chat.messages`, no app-auth); a `chat.bot` app cannot add/remove reactions → OpenAB status-reaction UX is a no-op on this platform. [https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create] +- 2026-07-04 (A) Max message payload (text+cards) is **32,000 bytes**; longer content must be split into multiple messages (adapter chunks conservatively at 4096 chars). [https://developers.google.com/workspace/chat/create-messages] +- 2026-07-04 (A) With app auth, `messages.patch` (edit) and `messages.delete` operate on **only the app's own** messages; edit `updateMask` supports `text`. [https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch] +- 2026-07-04 (A) Attachment upload limit is **200 MB**; some file types are blocked (an attachment message can't also carry accessory widgets). OpenAB downloads far below this (image 10 MB / file 512 KB / audio 25 MB) and skips Drive-sourced & video attachments. [https://developers.google.com/workspace/chat/upload-media-attachments] +- 2026-07-04 (A) Rate limits: **3,000 message writes/min per project** and **1 write/sec per space** (shared across apps; 10/sec only during data import); 429 → truncated exponential backoff. [https://developers.google.com/workspace/chat/limits] +- 2026-07-04 (A) Slash commands are registered in the Cloud console with a **commandId 1–1000** and delivered as a `MESSAGE` event carrying `message.slashCommand` / `message.annotation.slashCommand`. [https://developers.google.com/workspace/chat/commands] +- 2026-07-04 (A) In spaces the app is only invoked when @mentioned (mention stripped into `argumentText`); DMs deliver every message — so mention-gating is enforced platform-side, not by OpenAB's `@mention` string check. [https://developers.google.com/workspace/chat/receive-respond-interactions] +- 2026-07-04 (A) The `MESSAGE` interaction event is defined as "A user messages a Chat app"; other bots'/apps' messages are not delivered, and there is no typing/composing API for apps. [https://developers.google.com/workspace/chat/receive-respond-interactions] +- 2026-07-04 (A) Inbound webhooks are signed by `chat@system.gserviceaccount.com` (RS256, JWKS at `oauth2/v3/certs`); OpenAB verifies issuer (`accounts.google.com`), audience, exp, and the `email` claim. [https://developers.google.com/workspace/chat/receive-respond-interactions] \ No newline at end of file From af550e9790045bbd612b5709649e7a8ce597ab63 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:14 +0000 Subject: [PATCH 08/37] docs(platforms): docs/platforms/feishu.md (schema v1) --- docs/platforms/feishu.md | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/platforms/feishu.md diff --git a/docs/platforms/feishu.md b/docs/platforms/feishu.md new file mode 100644 index 000000000..b2242ccbc --- /dev/null +++ b/docs/platforms/feishu.md @@ -0,0 +1,98 @@ +--- +platform: feishu +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# Feishu / Lark — platform notes + +Engineering-facing capability & quirks reference for the Feishu / Lark adapter. For operator setup see `docs/feishu.md`. Follows the schemas in [`README.md`](./README.md). + +The adapter lives in the **gateway** crate (`crates/openab-gateway/src/adapters/feishu.rs`), not in a core `ChatAdapter` impl. Core talks to a generic `GatewayAdapter` (`crates/openab-core/src/gateway.rs`) over a WebSocket reply/response protocol; the gateway process runs the actual Feishu API calls. `domain=feishu` → `open.feishu.cn`, `domain=lark` → `open.larksuite.com` (`api_base()` `feishu.rs:290`). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | Both: **WebSocket long-connection** (default; protobuf `pbbp2.Frame`, `feishu.rs:24`, ACK per event) and **webhook** (`FEISHU_CONNECTION_MODE=webhook`). Ingested event: `im.message.receive_v1`. | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | +| inbound_auth | Webhook: L1 = **SHA256(timestamp + nonce + encrypt_key + body)** header signature (`verify_signature` `feishu.rs:3229`) + optional constant-time `verification_token`; event body **AES-256-CBC** with key = **SHA256(encrypt_key)**, IV = first 16 bytes of the ciphertext (`decrypt_event` `feishu.rs:3250`). WS: auth via AppID+AppSecret handshake to fetch the endpoint. | [Encrypt/verify events](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case) | +| threads | **Native.** Messages carry `root_id` / `parent_id`; reply via `POST /im/v1/messages/{id}/reply` with optional `reply_in_thread=true` (default `false`). If the parent is already a thread, replies stay in-thread automatically. `parent_id` for a thread always points at the thread root. | [Reply message](https://open.feishu.cn/document/server-docs/im-v1/message/reply) | +| slash_commands | **No native slash commands.** Bots have a **custom menu** (`application.bot.menu_v6` / menu-click event; ≤3 main + ≤5 sub, **DM-only**, no group support). Command-style input is just plain text. | [Bot customized menu](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu) | +| mentions | `@mention` via `event.message.mentions[]` (each has a `key` placeholder like `@_user_1` in text + `id.open_id`). Bot detects itself by matching its own `open_id` (resolved via `/bot/v3/info` `feishu.rs:916`). | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | +| emoji_reactions | Bot **can add** (`POST /im/v1/messages/{id}/reactions`) and **remove** (list → find own `reaction_id` → `DELETE`). Bot **can receive** reaction events via `im.message.reaction.created_v1` / `im.message.reaction.deleted_v1` (subscription required). | [Add reaction](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create) · [reaction.created event](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/events/created) | +| edit_message | **Yes**, own message only (calling identity must equal sender, else errcode 230071). `PATCH /im/v1/messages/{id}` updates `text` / `post`. Officially documented **hard cap of 20 edits per message** ("一条消息最多可编辑 20 次"), returning **errcode 230072** when exceeded; edit-time window is **set by the enterprise admin** (errcode 230075 when the window is exceeded — *not* a fixed 14-day limit). Card (`interactive`) updates use a separate endpoint and are not subject to the 20-edit cap. Global API frequency limit 1000/min · 50 QPS (errcode 230020). | [Edit message](https://open.feishu.cn/document/server-docs/im-v1/message/update) | +| delete_message | **Yes**, own message, `DELETE /im/v1/messages/{id}` (recall). Not subject to the edit cap. Deleting others' messages is admin/permission-gated, not a bot capability. | [Recall message](https://open.feishu.cn/document/server-docs/im-v1/message/delete) | +| rich_content | `post` (rich text: text/link/at/img/code_block), `interactive` **CardKit v2** cards (buttons, markdown, tables, streaming). Plain markdown is **not** natively rendered as a text message — must be converted to `post` or a card. | [Post content](https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json) · [Card JSON v2](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/feishu-cards/card-json-v2-structure) | +| attachments | Inbound: image / file / audio / media / post-embedded images; downloaded via `/im/v1/messages/{id}/resources/{key}`. Outbound: images/files uploaded first, then referenced by key. Feishu file-size limits are large (image ~10 MB, file ~30–100 MB tier-dependent). | [Get message resource](https://open.feishu.cn/document/server-docs/im-v1/message/get-2) | +| message_length_limit | **text ≤ 150 KB**; **post / card ≤ 30 KB** (per `content` JSON; card templates / style tags can inflate rendered size beyond the request body). | [Send message](https://open.feishu.cn/document/server-docs/im-v1/message/create) | +| dm_support | Yes — `chat_type = "p2p"`. | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | +| group_model | `chat` (group). Event `chat_type`: `p2p` (DM) vs group; each has a stable `chat_id` (`oc_…`). No separate channel/thread taxonomy — threads are message-level (`root_id`). | [Chat concepts](https://open.feishu.cn/document/server-docs/group/chat/chat-overview) | +| group_sender_identity | **Yes, stable & always present.** `sender.sender_id.open_id` (`ou_…`) is a per-app stable user id delivered in every group event; not consent-gated (display-name lookup via Contact API may need scope). | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | +| send_model | **Push model** — `POST /im/v1/messages` (or `/reply`) with a `tenant_access_token` (TTL ~7200 s, auto-refreshed). No per-message reply-window / token TTL like LINE; any message can be sent as long as the bot is in scope / in the group. The reply API's `uuid` is only a 1-hour idempotency window, not a reply deadline. | [Send message](https://open.feishu.cn/document/server-docs/im-v1/message/create) · [tenant_access_token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal) | +| proactive_push | Yes, to users **within the bot's availability scope** and groups the bot has joined (with speaking permission). Rate limits: **5 QPS per same-user**, **5 QPS shared per same-group**, global **1000/min · 50 QPS**. User opt-out → errcode 230053; user outside availability scope / disabled → errcode 230013. No hard daily quota. | [Send message rate limits](https://open.feishu.cn/document/server-docs/im-v1/message/create) | +| bot_to_bot | **Effectively no.** Feishu does **not** push another bot's messages to a bot's WebSocket, and marks other bots' messages as `sender_type="user"`, so a peer bot is indistinguishable without an explicit id allowlist. *(Platform docs are silent on bot→bot event delivery — behavior verified only via the adapter, not an official statement.)* | Adapter comment `feishu.rs:1158`, `parse_message_event` `feishu.rs:412` · [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | +| typing_indicator | **Not exposed** to bots — no public "typing"/"inputting" API is documented (Message FAQ is silent on it). OpenAB signals progress with emoji reactions / a streaming card instead. *(Absence confirmed by omission; cannot be stated as an affirmative platform guarantee.)* | [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | Replies sent as **`post`** (rich text) so markdown renders; `send_post_message`. `send_text_message` kept only for webhook fallback/tests (`#[allow(dead_code)]`). | `send_post_message` `feishu.rs:2160`, `send_text_message` `feishu.rs:2230` | +| message_split/chunking | implemented | Long replies chunked at `message_limit` (default **4000**, `FEISHU_MESSAGE_LIMIT` `feishu.rs:228`) via `split_text` (UTF-8-safe, breaks on newline/space); partial-chunk failure is reported as failure to core. | chunk branch `feishu.rs:2701`, `split_text` call `feishu.rs:2707`, def `feishu.rs:3158` | +| streaming | workaround | No native content-streaming API. Emulated by **PATCH-editing a `post`** in place, with an **auto post→card promotion**: `StreamingMode::Auto` (default) promotes to a CardKit v2 streaming card when text is large (`card_promote_bytes`, default 4000), or contains a code fence / markdown table (`should_use_card`), or when the 20-edit post cap is hit (in `handle_card_edit`). `post` mode = kill-switch. Idle reaper finalizes cards. | `should_use_card` `feishu.rs:1550`, `handle_card_edit` `feishu.rs:2799`, `run_idle_reaper` `feishu.rs:3105` | +| reply/quote | implemented | `send_message_with_reply` → gateway `quote_message_id`; adapter prefers `quote_message_id` over `thread_id`, uses `/messages/{id}/reply`, and falls back to a plain send if reply-to fails. | core `send_message_with_reply` `gateway.rs:481`; adapter reply-target `feishu.rs:2645`, reply-path `feishu.rs:2168` | +| edit_message | implemented | Real in-place PATCH (`edit_feishu_message`). Core overrides the trait default (which returns "unsupported") because Feishu acks writes: `edit_message` uses an 800 ms request/response so it can see cap signals. Preemptive local cap at 18 edits (`FEISHU_EDIT_CAP` `feishu.rs:1259`, 2-edit safety margin) + server errcode 230072 detection. | `edit_feishu_message` `feishu.rs:1385`; core edit path `gateway.rs:585`; trait default `adapter.rs:330` | +| delete_message | implemented | Native `DELETE /im/v1/messages/{id}` (`delete_feishu_message`); core overrides the trait default (edit-to-zero-width) because Feishu has a real delete. Used to remove the half-edited placeholder during cap-recovery / card-promotion. | `delete_feishu_message` `feishu.rs:1496`; core override `gateway.rs:665`; trait default `adapter.rs:353` | +| emoji_reactions | partial | `add_reaction` implemented; `remove_reaction` implemented via list→match-own-`reaction_id`→DELETE. **Limited to an 8-emoji hard-coded map** (👀🤔🔥👨‍💻⚡🆗👍😱); anything else is silently dropped. Inbound reaction **events are not ingested** (adapter only parses `im.message.receive_v1`). | `emoji_to_feishu_reaction` `feishu.rs:2289`, `add_reaction` `feishu.rs:2303`, `remove_reaction` `feishu.rs:2328`; core dispatch `gateway.rs:541`/`:563` | +| threads/topics | partial | Native thread replies work (via `root_id` / `reply`). But the gateway `create_topic` command (from core `create_thread`) is a **no-op** — the Feishu adapter explicitly skips it and core falls back to replying in the same channel. | skip: `feishu.rs:2578`; core create_thread→create_topic `gateway.rs:490` | +| media_inbound | implemented | Images (resized to ≤1200 px, JPEG q75, ≤10 MB; GIF passthrough), text files (extension-allowlisted, ≤512 KB), audio (≤25 MB, Whisper cap), and `post`-embedded images. Oversized/unsupported → `Attachment::rejected`. Constants at `feishu.rs:1786`–`1789`, `:2049`. | `download_feishu_image` `feishu.rs:1820`, `download_feishu_file` `feishu.rs:1933`, `download_feishu_audio` `feishu.rs:2052` | +| voice_stt | partial | Adapter only **downloads** audio into an `audio` attachment (≤25 MB); actual speech-to-text is done downstream (Whisper) by core, not in the adapter. | `download_feishu_audio` `feishu.rs:2052` (`AUDIO_MAX_DOWNLOAD` `feishu.rs:2049`) | +| trust_gate | implemented | Two layers: **adapter-side** `allowed_users` / `allowed_groups` allowlists + bot/self filtering in `parse_message_event` (`feishu.rs:425` user allowlist, `feishu.rs:444` group allowlist); **core-side** shared ingress `gate_incoming` (L2 scope + L3 identity) before dispatch. | adapter `feishu.rs:425`; core `gate_incoming` call `gateway.rs:1198`, impl `adapter.rs:495` | +| deny_echo | implemented | On core L3 `DenyIdentity`, gateway echoes a throttled deny message ("⚠️ You are not on this bot's trusted list.\nYour ID: …\nAsk the admin to add it to allowed_users."), ≤1 echo per platform:sender per window. `DenyScope` → silent drop. | `gateway.rs:1201` (message text `:1221`, silent DenyScope `:1228`) | +| mention_gating | implemented | Groups require @mention unless bot has participated in the thread (Discord-style "involved"). Modes via `FEISHU_ALLOW_USER_MESSAGES`: `involved` / `mentions` / `multibot_mentions` (default — require @mention if another bot is in the thread). Participation cache TTL = `FEISHU_SESSION_TTL_HOURS` (24 h). | `parse_message_event` gate `feishu.rs:569`; `detect_and_mark_multibot` `feishu.rs:2408` | +| slash_commands | n/a | Platform has no slash-command system; `/reset` and `/cancel` arrive as plain text and are intercepted by **core** (not the adapter). Adapter does no slash parsing. | core interception `gateway.rs:1376` (`/reset`) / `gateway.rs:1389` (`/cancel`); platform: no native support | +| multibot | partial | `FEISHU_ALLOW_BOTS` (off/mentions/all) + `FEISHU_TRUSTED_BOT_IDS` + per-chat `max_bot_turns` (default 20) loop-guard. **Caveat:** Feishu marks other bots as `sender_type="user"` and doesn't push bot messages over WS, so without `trusted_bot_ids` peer bots can't be identified; `multibot_mentions` detection is done via **@mention of a known bot id**, not sender type. | `parse_message_event` `feishu.rs:417`, bot-turn guard `feishu.rs:1142`, `detect_and_mark_multibot` `feishu.rs:2408` | +| group_routing | implemented | Routing keyed by `chat_id`; `channel_type` = `direct` (p2p) vs `group`; `thread_id` from `root_id` / `parent_id`. Dedupe by `event_id` + `message_id`; self-echo dedupe on sent `message_id` (Feishu pushes the bot's own messages back). | `parse_message_event` `feishu.rs:557`, dedupe `feishu.rs:1116` / `:1138` | + +## 3. Platform quirks (`platform-quirks` v1) + +### 20-edit-per-message cap → streaming card promotion + +Feishu **officially documents** a hard limit of 20 edits per message: `PATCH /im/v1/messages/{id}` for `text`/`post` returns **errcode 230072** once exceeded ("一条消息最多可编辑 20 次" / "the message has reached the number of times it can be edited"). This is the central constraint shaping OpenAB streaming. The adapter (a) preemptively stops at 18 edits (`FEISHU_EDIT_CAP` `feishu.rs:1259`, 2-edit safety margin for in-flight races), (b) parses the body `code` (JSON-code-first, substring fallback) to catch server-side 230072 even on HTTP 200, and (c) on cap, promotes to a **CardKit v2 streaming card** (no such cap) or, in `post` mode, lets core's finalize path delete the placeholder and send fresh content. Card-update uses its own 5-QPS/message frequency limit (errcode 230020) instead. (The edit-*time* window is separately admin-configured, errcode 230075.) + +### post-vs-card streaming state machine + +`FEISHU_CARD_STREAMING_MODE` = `auto` (default) | `card` | `post`. In `auto`, short replies stay a native `post` reply (nicer thread UI); long / code-fence / table replies (which `markdown_to_post` degrades — tables are dropped entirely, Issue #1124) promote to a card (`should_use_card` `feishu.rs:1550`, with `has_code_fence` / `has_markdown_table`). The post→card swap is invisible to core: the gateway keeps reporting the original `om_post` message_id back so core's edit loop is oblivious. `FEISHU_CARD_FALLBACK_TO_POST=true` (default) is a second safety net back to the post path on any card failure. + +### markdown rendering is lossy + +`markdown_to_post` (`feishu.rs:1593`) converts to Feishu `post`: code fences → `code_block`, `[t](url)` → `a`, and **bold/italic/strike markers are stripped** (not rendered). **Markdown tables are not representable** in `post` at all — that's a primary reason the auto-promotion to CardKit exists (cards render tables/markdown natively). + +### message_id shape validation (defence-in-depth) + +Any command that interpolates a message_id into a REST path (`edit_message`, `delete_message`, `add_reaction`, `remove_reaction`) is gated by `is_valid_feishu_message_id` (`feishu.rs:1270`; `om_` + `[A-Za-z0-9_]`, len 4–128), rejecting crafted ids containing `/ ? #`. Internal card message_ids from trusted gateway session state bypass the check. The `"draft"` sentinel is a benign no-op skip. + +### bot identity & self/peer-bot handling + +Bot `open_id` is resolved at startup and on WS reconnect via `/bot/v3/info` (`feishu.rs:916`); without it, mention gating can't work. Feishu pushes the bot's **own** sent messages back over WS (handled by self-echo dedupe on the returned `message_id`) and does **not** push *other* bots' messages to a bot — so multibot detection relies on @mentions of known bot ids, not on `sender_type` (which is `user` for peer bots). + +### webhook security posture + +Webhook path enforces: 1 MB body cap (`feishu.rs:3192`), per-IP rate limit, SHA256 signature (only if `FEISHU_ENCRYPT_KEY` set — a warning is logged and verification is **skipped** if unset), AES-256-CBC decrypt of `encrypt` payloads (key = SHA256(encrypt_key), IV = first 16 bytes), constant-time `verification_token` check, and the URL-verification `challenge` handshake. + +### Findings log + +- 2026-07-04 (A) **20-edit-per-message hard cap is officially documented** (errcode 230072); edit is sender-only (230071); edit-time window is admin-configured (230075) — *not* a fixed 14-day/230031 limit as previously drafted. Global 1000/min·50 QPS, per-message 5 QPS (230020). [https://open.feishu.cn/document/server-docs/im-v1/message/update] +- 2026-07-04 (A) Text content ≤150 KB; post/card ≤30 KB; 5 QPS/user & 5 QPS/group shared, 1000/min·50 QPS global; user opt-out → 230053, out-of-scope → 230013. [https://open.feishu.cn/document/server-docs/im-v1/message/create] +- 2026-07-04 (A) Reply API supports native threads via `reply_in_thread` (default false) + `root_id`/`parent_id`; no reply-time deadline (the `uuid` is a 1-hour idempotency window only). [https://open.feishu.cn/document/server-docs/im-v1/message/reply] +- 2026-07-04 (A) Bot can add reactions and remove by reaction_id; inbound reaction events exist as `im.message.reaction.created_v1`/`deleted_v1` (subscription required) — OpenAB adapter does not yet ingest them. [https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create] +- 2026-07-04 (A) No native slash commands; only a DM-only custom bot menu (≤3 main/≤5 sub). OpenAB treats `/reset` etc. as plain text, intercepted in core. [https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu] +- 2026-07-04 (A) No public bot typing-indicator API and no documented bot→bot event delivery — Message FAQ is silent on both; OpenAB uses reactions/streaming card for progress and infers peer bots via @mention only. [https://open.feishu.cn/document/server-docs/im-v1/faq] +- 2026-07-04 (B) Adapter relies on the 230072 cap → CardKit streaming-card promotion; card streaming (S6) defaults to `auto`. [crates/openab-gateway/src/adapters/feishu.rs:1550, :2799] +- 2026-07-04 (B) `create_topic` (from core `create_thread`) is a deliberate no-op in the Feishu adapter — core falls back to same-channel reply. [crates/openab-gateway/src/adapters/feishu.rs:2578] +- 2026-07-04 (B) Reaction emoji support limited to an 8-entry hard-coded map; unmapped emojis are silently dropped. [crates/openab-gateway/src/adapters/feishu.rs:2289] +- 2026-07-04 (B) Slash commands `/reset` and `/cancel` are intercepted in core `gateway.rs` (:1376/:1389), not the adapter; `dispatch.rs` does not hold the literals. [crates/openab-core/src/gateway.rs:1376] \ No newline at end of file From cf47c7c31c75bb086e424aacb0c6acca4ca56748 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:16 +0000 Subject: [PATCH 09/37] docs(platforms): docs/platforms/slack.md (schema v1) --- docs/platforms/slack.md | 87 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/platforms/slack.md diff --git a/docs/platforms/slack.md b/docs/platforms/slack.md new file mode 100644 index 000000000..fb87683c3 --- /dev/null +++ b/docs/platforms/slack.md @@ -0,0 +1,87 @@ +--- +platform: slack +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# Slack — platform notes + +Engineering-facing capability & quirks reference for the Slack adapter. For operator setup see `docs/slack.md`. Follows the schemas in [`README.md`](./README.md). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | socket-mode (persistent WebSocket obtained from `apps.connections.open`; no public URL). | [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) | +| inbound_auth | None per-event. The WebSocket is pre-authenticated by the app-level token (`xapp-`) sent in the `Authorization` header to open it; inbound events need no HMAC/signature validation (explicitly unlike the HTTP Events API, where each event must be validated). | [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) | +| threads | native — a thread is implicit: any message posted with `thread_ts` = a parent message's `ts` becomes a reply in that thread. `reply_broadcast=true` optionally also surfaces the reply to the whole channel (default `false` = thread-only). | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | +| slash_commands | supported but **HTTP-only** — registered in app config with a Request URL; delivered via HTTP POST, not Socket Mode events. Developer slash commands **cannot be invoked in message threads** (only built-ins like `/remind` can). | [Implementing slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands) | +| mentions | `@bot` renders in event text as `<@BOT_UID>` (or labelled `<@BOT_UID\|handle>`); a dedicated `app_mention` event also fires. DMs are an implicit mention (no `app_mention`). | [message event](https://docs.slack.dev/reference/events/message/) | +| emoji_reactions | Bot **can add** (`reactions.add`) and **remove** (`reactions.remove`) with `reactions:write`; **receives** `reaction_added`/`reaction_removed` events. Per-item caps on distinct emoji and per-person reactions. | [reactions.add](https://docs.slack.dev/reference/methods/reactions.add) | +| edit_message | Yes — `chat.update`, but **only messages the bot itself authored** (`cant_update_message` otherwise). Tier-3 rate limit; `edit_window_closed` if the workspace's message-edit settings forbid the edit. | [chat.update](https://docs.slack.dev/reference/methods/chat.update) | +| delete_message | `chat.delete` with `chat:write` — with a bot token, deletes **only messages posted by that bot**; cannot delete users'/other bots' messages (no impersonation accommodation). | [chat.delete](https://docs.slack.dev/reference/methods/chat.delete) | +| rich_content | Block Kit (sections, buttons, cards, and a `markdown` block) + legacy `mrkdwn`. A Block Kit `markdown` block renders real headings/lists/tables/code fences. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | +| attachments | Inbound: any file the bot can see, downloaded with the bot token (`files:read`). Outbound uploads capped at **1 GB per file** (hard limit on all plans); free workspaces additionally cap **total storage at 5 GB**. Note: `files.upload` is deprecated — **sunset 2025-11-12**, replaced by `files.getUploadURLExternal` + `files.completeUploadExternal`. | [1 GB / 5 GB limits](https://docs.slack.dev/changelog/2019-03-wild-west-for-files-no-more/) · [files.upload retirement](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/) | +| message_length_limit | `text`: recommended ≤ 4,000 chars, hard-truncated by Slack at 40,000. `markdown_text` (and the Block Kit `markdown` block the adapter uses): up to 12,000 chars. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | +| dm_support | Yes — 1:1 IM channels (channel IDs start with `D`). | [message event](https://docs.slack.dev/reference/events/message/) | +| group_model | workspace → channels (public `C…` / private `G…`), plus IM (`D…`) and multi-party IM (`mpim`); threads inside channels. | [message event](https://docs.slack.dev/reference/events/message/) | +| group_sender_identity | Yes — stable per-user `user` (`U…`) ID on every message event; resolvable to display/real name via `users.info`. Bot senders carry `bot_id` (`B…`) instead. Not consent-gated within an authorized workspace. | [message event](https://docs.slack.dev/reference/events/message/) | +| send_model | push — no reply-window/token TTL. Any `chat.postMessage` targeting a channel the bot is in; threading is `thread_ts`, not a time-bounded reply token. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | +| proactive_push | Yes (bot may post unsolicited to channels it's a member of). Rate: no more than **1 message/second/channel** (short bursts tolerated). `chat.postMessage` is a "Special Tier" method carrying both this per-channel limit and a workspace-wide limit. | [Rate limits](https://docs.slack.dev/apis/web-api/rate-limits/) | +| bot_to_bot | Yes — other bots' messages are delivered as `message` events with `subtype: "bot_message"` and a `bot_id`/`bot_profile`; the receiving app must opt to process them. | [bot_message event](https://docs.slack.dev/reference/events/message/bot_message/) | +| typing_indicator | Not used as a typing dot. Assistant mode instead surfaces an ephemeral status line via `assistant.threads.setStatus` ("Thinking…"). | [assistant.threads.setStatus](https://docs.slack.dev/reference/methods/assistant.threads.setStatus) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | `chat.postMessage` with Block Kit `markdown` blocks + `text`/mrkdwn fallback. Graceful degrade to text-only on `invalid_blocks`/`msg_blocks_too_long`. | slack.rs:442, slack.rs:1716 | +| message_split/chunking | implemented | `message_limit()` = 11,900 (Block Kit `markdown` cap 12k minus ~100 headroom, `MARKDOWN_BLOCK_LIMIT` at slack.rs:1668); router splits at that bound and each chunk is one `markdown` block via `format::split_message`. Known gap: `split_message` isn't table-aware, so a single table > 11,900 splits mid-table into raw pipes (continuation blocks lack the header/separator rows). | slack.rs:433, slack.rs:1702 | +| streaming | implemented | Native streaming via `chat.startStream`/`appendStream`/`stopStream` when `streaming && assistant_mode && !other_bot_present` (`uses_native_streaming`); otherwise degrades to a post+edit placeholder; `streaming=false` = send-once. `stopStream` closes content-free (append semantics would duplicate — #1055), then `chat.update` writes the clean finalized Block Kit copy. | slack.rs:555, slack.rs:637 | +| reply/quote | partial | No `send_message_with_reply` override → uses the trait default (adapter.rs:336) = plain `send_message` (reply_to ignored). Slack threading is instead expressed via `thread_ts` on the channel ref. | adapter.rs:336, slack.rs:442 | +| edit_message | implemented | `chat.update` with the same block payload + text-only degrade on `invalid_blocks`/`msg_blocks_too_long`. Also the substrate for degraded-stream mid-edits and stream finalization. | slack.rs:527 | +| delete_message | workaround | No `delete_message` override → the trait default (adapter.rs:353) edits the message to a zero-width space (`\u{200b}`) via `edit_message` rather than calling `chat.delete`. Effectively blanks, doesn't remove. | adapter.rs:353 | +| emoji_reactions | implemented | `reactions.add`/`reactions.remove`; treats `already_reacted`/`no_reaction` as success (idempotent). Unicode→Slack shortname map (slack.rs:20) is limited to a default set; unmapped emoji fall back to `grey_question`. | slack.rs:489, slack.rs:508, slack.rs:20 | +| threads/topics | implemented | `create_thread` is a no-op mapping: returns a channel ref with `thread_id = trigger ts` (Slack threads are implicit, created by posting with `thread_ts`). No gateway `create_topic` (native adapter). | slack.rs:473 | +| media_inbound | implemented | Images (download+encode), text files (5-file / 1 MB total cap, `TEXT_FILE_COUNT_CAP`/`TEXT_TOTAL_CAP`, mirroring Discord #291), audio → STT. Private files fetched with the bot token; failed images trigger a `files:read`/format hint message. | slack.rs:1279, slack.rs:1376 | +| voice_stt | implemented | Audio attachments transcribed via `media::download_and_transcribe` when `stt.enabled`; transcript injected as a leading text block + best-effort echo (`stt::post_echo`). STT disabled → 🎤 reaction ack. | slack.rs:1292, slack.rs:1335 | +| trust_gate | partial | Native adapter does NOT use the shared `AdapterRouter::gate_incoming` (L3 identity trust) — that's wired only for gateway (gateway.rs:1198) and discord (discord.rs:1049). Slack enforces inline allowlists: `allowed_channels` (slack.rs:1219) and `allowed_users` (slack.rs:1224), plus `trusted_bot_ids` resolution (B…→U… via `bots.info`, slack.rs:308) for bot senders. | slack.rs:1219, slack.rs:308 | +| deny_echo | partial | On a denied user, no text reply — reacts 🚫 to the offending message (`add_reaction` at slack.rs:1236, inside the `allowed_users` deny branch at slack.rs:1224). This is the native adapter's own deny UX, not the gateway's `DenyIdentity` ID-echo path (gateway.rs:1201). | slack.rs:1236, slack.rs:1224 | +| mention_gating | implemented | Per `allow_user_messages`: `Mentions` requires `<@bot>`; `Involved` requires bot participation in the thread; `MultibotMentions` additionally requires an @mention once another bot is in the thread (slack.rs:1069). DMs are implicit mentions. `app_mention` handles the @-path (deduped against `message` at slack.rs:966). | slack.rs:1029, slack.rs:966 | +| slash_commands | not-implemented | `slash_commands` and `interactive` envelopes are ack'd and dropped: slash commands are blocked in thread composers and channel-level delivery lacks the `thread_ts` needed to route to a session. No in-text `/reset`/`/cancel` parsing in the native path either (that lives in the gateway path, gateway.rs:1004). | slack.rs:797 | +| multibot | implemented | Eager other-bot detection on inbound bot messages (`note_other_bot_in_thread`, slack.rs:134, invoked at slack.rs:901), persisted to a disk cache (irreversible). Disables streaming (`use_streaming`/`uses_native_streaming` gate on `other_bot_present`) and, with `MultibotMentions`, requires @mention. Consecutive-bot-turn cap (`MAX_CONSECUTIVE_BOT_TURNS` = 1000, enforced at slack.rs:980) + `BotTurnTracker` soft/hard limits guard loops. | slack.rs:134, slack.rs:901, slack.rs:980 | +| group_routing | implemented | Routed through `Dispatcher`; key is grouping-dependent — `slack:` in `Thread` mode or `slack::` in `Lane` mode (`Dispatcher::key`, dispatch.rs:295), with `thread_id` falling back to `channel_id` outside a thread (slack.rs:1524). Sender context is serialized with the Slack-native `thread_ts` key (slack.rs:1491) so agents calling the API directly see the right field. | dispatch.rs:295, slack.rs:1524, slack.rs:1491 | + +## 3. Platform quirks (`platform-quirks` v1) + +### Socket Mode keepalive (deaf-socket guard) +Slack's inbound WebSocket can go half-open (NAT idle-timeout silently drops inbound frames with no Close/FIN), leaving `read.next()` blocked forever so the reconnect loop never fires — the bot appears connected but goes deaf. The adapter pings every 30s (`PING_INTERVAL_SECS`) and force-reconnects if no inbound frame — including Slack's own pings — arrives within 75s (`IDLE_TIMEOUT_SECS`). Backoff doubles to a 30s cap, mirroring the gateway: 1,2,4,8,16,30,30… (slack.rs:699, slack.rs:711). + +### Native streaming duplication trap (#1055) +`chat.stopStream`'s `markdown_text` **appends**, it does not replace. Passing the full reply at finish would duplicate the entire message. The adapter closes the stream content-free, then `chat.update`s the finalized Block Kit copy. On the active path a failed final `chat.update` must NOT fall back to `postMessage` (would post a duplicate); on the degraded (post+edit) path it must, since no streamed content exists (slack.rs:637). + +### Block Kit markdown vs legacy mrkdwn +Messages are sent as Block Kit `markdown` blocks (12k cap; real headings/tables/code fences), with a `markdown_to_mrkdwn` `text` fallback for notifications/a11y. `message_limit` is bumped 4000→11,900 to keep typical Markdown tables in one block; `renders_native_tables()=true` tells the router to skip the `convert_tables` pre-pass. Workspaces that reject the block (`invalid_blocks`/`msg_blocks_too_long`) get an automatic text-only retry (`is_block_payload_rejected` matches the trailing error code exactly, so `invalid_blocks_field` does not falsely trigger) (slack.rs:1685, slack.rs:451). + +### app_mention vs message dedup +Both `app_mention` and `message` events can fire for one @mention. The adapter routes the @-path through `app_mention` and skips mention-bearing `message` events (except in DMs, where `app_mention` doesn't fire — slack.rs:968). Eager multibot detection (slack.rs:897) and bot-turn tracking (slack.rs:907) run BEFORE the self/bot gates (own-message skip at slack.rs:964) so own and filtered-out bot messages still count and are still detected (mirrors Discord #481/#483). + +### Positive-only, fail-closed caches +Participation and multibot are irreversible states, so their caches store positive results only; multibot is also disk-persisted (`multibot_cache`) to survive restarts. Thread-history checks (`bot_participated_in_thread`, and the `AllowBots::All` consecutive-bot loop cap) fail **closed** — an API error rejects the message rather than risk an unauthorized/looping response (slack.rs:327, slack.rs:1006). + +### Native trust divergence +Unlike the gateway/Discord paths (which call `AdapterRouter::gate_incoming`), the Slack adapter predates the unified trust gate and enforces access with inline `allowed_channels`/`allowed_users`/`trusted_bot_ids` checks. Deny UX is a 🚫 reaction, not the gateway's `DenyIdentity` identity-echo. A future consolidation onto `AdapterRouter::with_trust` (adapter.rs:485) would unify this (slack.rs:1219). + +### Findings log +- 2026-07-04 (A) `files.upload` sunsets 2025-11-12, replaced by `files.getUploadURLExternal`+`files.completeUploadExternal`; the 1 GB/file and 5 GB/free-workspace caps are separate, longstanding limits (the 5 GB cap dates to the 2019-03 changelog). [https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/] +- 2026-07-04 (A) Socket Mode is pre-authenticated by the `xapp-` app-level token (sent in the `Authorization` header to `apps.connections.open`); inbound events need no per-event HMAC/signature validation — the docs state this explicitly, unlike the HTTP Events API. [https://docs.slack.dev/apis/events-api/using-socket-mode] +- 2026-07-04 (A) Developer slash commands cannot be invoked in message threads and are delivered over HTTP only — matching the adapter's decision to ack-and-drop `slash_commands` envelopes. [https://docs.slack.dev/interactivity/implementing-slash-commands] +- 2026-07-04 (A) `chat.update` fails with `cant_update_message` on non-own messages and `edit_window_closed` under workspace edit settings (Tier 3); `chat.delete` with a bot token deletes only that bot's own messages — which is why OpenAB's delete uses a zero-width-space edit fallback rather than assuming cross-author delete. [https://docs.slack.dev/reference/methods/chat.delete] +- 2026-07-04 (A) `chat.postMessage` limits: `text` recommended ≤4,000 / truncated at 40,000; `markdown_text` (and the Block Kit `markdown` block) up to 12,000 — the basis for the 11,900 `message_limit`. [https://docs.slack.dev/reference/methods/chat.postMessage] +- 2026-07-04 (A) Other bots' messages ARE delivered as `message` events with `subtype: bot_message` + `bot_id`; OpenAB gates them via `allow_bot_messages` and `trusted_bot_ids`. [https://docs.slack.dev/reference/events/message/bot_message/] +- 2026-07-04 (A) Proactive posting rate = no more than 1 message/second/channel (bursts tolerated; `chat.postMessage` is a Special Tier method); relevant to multibot loop caps. [https://docs.slack.dev/apis/web-api/rate-limits/] +- 2026-07-04 (B) `chat.stopStream` appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-`chat.update`. [openabdev/openab#1055] \ No newline at end of file From 4c794eb91abd7acffed59dd94ee382bcad09351a Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:17 +0000 Subject: [PATCH 10/37] docs(platforms): docs/platforms/line.md (schema v1) --- docs/platforms/line.md | 128 +++++++++++++++++++++++++++-------------- 1 file changed, 85 insertions(+), 43 deletions(-) diff --git a/docs/platforms/line.md b/docs/platforms/line.md index a8878ef3f..0b232096d 100644 --- a/docs/platforms/line.md +++ b/docs/platforms/line.md @@ -1,57 +1,99 @@ +--- +platform: line +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + # LINE — platform notes -Engineering-facing capability & quirks reference for the LINE adapter. For operator setup, see the LINE setup guide. This doc is maintained by the LINE maintainer. +Engineering-facing capability & quirks reference for the LINE adapter. For operator setup see `docs/line.md`. Follows the schemas in [`README.md`](./README.md). -> Structure: **(A) platform-intrinsic facts** (official-doc sourced) · **(B) OpenAB mapping** (code + PR sourced) · **Findings log**. See [`README.md`](./README.md) for the cross-platform matrix. +## 1. Platform capability (`platform-capability` v1) ---- +| Field | Value | Source | +|---|---|---| +| transport | webhook (HTTPS POST to the bot's registered endpoint; events arrive as a batched `events[]` payload) | [Receive messages (webhook)](https://developers.line.biz/en/docs/messaging-api/receiving-messages/) | +| inbound_auth | HMAC-SHA256 over the raw request body, keyed by the channel secret, Base64-encoded, compared to the `x-line-signature` header | [Messaging API reference — signature validation](https://developers.line.biz/en/reference/messaging-api/#signature-validation) | +| threads | none — no native threads/topics. LINE has flat 1:1 chats, group chats and multi-person "rooms"; there is no thread or topic primitive | [Source objects](https://developers.line.biz/en/reference/messaging-api/#source-user) | +| slash_commands | not a platform feature — no command registration/delivery API. Any `/cmd` is just plain message text | [Message event](https://developers.line.biz/en/reference/messaging-api/#message-event) | +| mentions | `mention.mentionees[]` on text message events; each mentionee carries an optional `userId` and an `isSelf` flag that is `true` for the bot itself (no username matching needed) | [Text message event — mention](https://developers.line.biz/en/reference/messaging-api/#wh-text) | +| emoji_reactions | Bot **cannot add/remove** reactions (no API). Bot **cannot receive** them either: the documented webhook event list (message, unsend, follow/unfollow, join/leave, member join/leave, postback, video-viewing-complete, beacon, account-link, membership) contains **no `reaction` event** — verified against the current Messaging API reference, which does not surface a reaction webhook to bots | [Webhook event objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) | +| edit_message | No — the API has no endpoint to edit an already-sent message | [Messaging API reference](https://developers.line.biz/en/reference/messaging-api/) | +| delete_message | No bot-initiated delete. Only the *user* can unsend, which delivers an `unsend` webhook to the bot; the bot cannot delete its own or others' messages | [unsend event](https://developers.line.biz/en/reference/messaging-api/#unsend-event) | +| rich_content | Rich messages: stickers, images, imagemap, buttons/confirm/carousel templates, and Flex Messages (JSON-defined layouts). No Markdown; text is plain (LINE emoji via product/emoji IDs) | [Message types](https://developers.line.biz/en/docs/messaging-api/message-types/) | +| attachments | Inbound via get-content by message ID (`/v2/bot/message/{id}/content` on the `api-data.line.me` host): images, video, audio, files. `contentProvider.type` is `"line"` (fetchable) or `"external"` (URL only, not fetchable via get-content). User-sent content auto-expires, so fetch promptly. Outbound media is sent by URL, not upload | [Get content](https://developers.line.biz/en/reference/messaging-api/#get-content); [Message types](https://developers.line.biz/en/docs/messaging-api/message-types/) | +| message_length_limit | 5000 characters per text message object (counted in UTF-16 code units; chunking required above this) | [Text message object](https://developers.line.biz/en/reference/messaging-api/#text-message); [Character counting in a text](https://developers.line.biz/en/docs/messaging-api/text-character-count/) | +| dm_support | Yes — 1:1 chat between a user and the LINE Official Account | [Source objects](https://developers.line.biz/en/reference/messaging-api/#source-user) | +| group_model | Two multi-user taxonomies: **group** (`groupId`) and **room** / multi-person chat (`roomId`), plus 1:1 **user** chats. No channels/spaces | [Group chats and multi-person chats](https://developers.line.biz/en/docs/messaging-api/group-chats/) | +| group_sender_identity | Consent-gated and unreliable: `userId` is **optional** in group/room source objects and is only present for users on LINE for iOS/Android; it can be absent otherwise | [Source objects — group](https://developers.line.biz/en/reference/messaging-api/#source-group) | +| send_model | Hybrid: **Reply API** (`/v2/bot/message/reply`) consumes a one-time `replyToken` from the inbound webhook and is free; **Push API** (`/v2/bot/message/push`) targets a user/group/room ID at any time and counts against quota. Reply token must be used within ~1 minute; LINE explicitly says the limit may change without notice and use beyond one minute isn't guaranteed — don't rely on it. Both endpoints accept up to 5 message objects per request | [Send reply message](https://developers.line.biz/en/reference/messaging-api/#send-reply-message); [Send messages](https://developers.line.biz/en/docs/messaging-api/sending-messages/) | +| proactive_push | Yes, via Push API, but metered: each plan has a monthly free-message quota (amount depends on subscription plan/region), with paid overage. A get-quota/consumption API exists. Up to 5 message objects per request | [Messaging API pricing](https://developers.line.biz/en/docs/messaging-api/pricing/) | +| bot_to_bot | No — LINE Official Accounts (bots) do not receive messages from other bots; webhook message events are for user-originated content | [Messaging API overview](https://developers.line.biz/en/docs/messaging-api/overview/) | +| typing_indicator | Yes — "Display a loading animation" endpoint, **1:1 chats only** ("You can't specify group chats or multi-person chats"), rate-limited to 100 req/s. It is a loading animation while the user is viewing the chat, not a per-keystroke typing indicator | [Display a loading indicator](https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/) | -## (A) Platform-intrinsic facts +## 2. OpenAB feature support (`openab-feature-support` v1) -Provider truths, independent of OpenAB. Source of authority = LINE official docs. +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | Outbound text via hybrid dispatch: tries Reply API first (free), falls back to Push API. Only `{"type":"text"}` objects are sent (no rich content) | `crates/openab-gateway/src/adapters/line.rs:646` (`dispatch_line_reply`) [PR #1291] | +| message_split/chunking | partial | Router-level `split_delivery` + per-adapter `message_limit` handle length bounds generically; the LINE dispatcher itself sends a single text object per reply and does not re-chunk. LINE's own cap is 5000 chars/object | `split_delivery` `crates/openab-core/src/adapter.rs:149`; `message_limit` trait `crates/openab-core/src/adapter.rs:309`; single-object dispatch `crates/openab-gateway/src/adapters/line.rs:683-686` | +| streaming | not-implemented | No native streaming; adapter does not override `uses_native_streaming` (trait default false) and LINE has no edit API to drive post+edit streaming. Effectively send-once/batched | trait `uses_native_streaming` default `crates/openab-core/src/adapter.rs:361`; `edit_message` default `crates/openab-core/src/adapter.rs:330` | +| reply/quote | workaround | LINE "reply" is a delivery mechanism (reply-token), not a UI quote. `dispatch_line_reply` uses the token to answer in-context; there is no message-quote rendering. Reply vs Push chosen by token freshness | `crates/openab-gateway/src/adapters/line.rs:676-712` [PR #1291] | +| edit_message | n/a | LINE has no edit endpoint. Trait default `edit_message` returns `"edit_message not supported"`; adapter does not override | trait default `crates/openab-core/src/adapter.rs:330-332` | +| delete_message | n/a | No LINE delete endpoint. Trait default `delete_message` falls back to editing to a zero-width space — which also fails on LINE since edit is unsupported | trait default `crates/openab-core/src/adapter.rs:353-355` | +| emoji_reactions | n/a | LINE exposes no add/remove-reaction API. The gateway dispatcher explicitly ignores `add_reaction`/`remove_reaction` commands (logs "ignoring unsupported command", returns false) | `crates/openab-gateway/src/adapters/line.rs:653-659` [PR #1291] | +| threads/topics | n/a | LINE has no thread primitive. The `create_topic` command is explicitly ignored by the dispatcher alongside the reaction commands | dispatch `crates/openab-gateway/src/adapters/line.rs:653-659`; trait `create_thread` `crates/openab-core/src/adapter.rs:315` | +| media_inbound | partial | Images and audio are downloaded via get-content (`/v2/bot/message/{id}/content`), size-guarded (Content-Length pre-check + streaming cap), and stored (images are resized/compressed; audio stored as-is). **external**-provider content and missing access-token produce a status-only attachment (not dropped); video/files are not ingested (event filter passes only text/image/audio) | image `crates/openab-gateway/src/adapters/line.rs:401`; audio `crates/openab-gateway/src/adapters/line.rs:511`; type filter `crates/openab-gateway/src/adapters/line.rs:215`; external/missing-token `crates/openab-gateway/src/adapters/line.rs:228-264` | +| voice_stt | not-implemented | Audio is downloaded and stored as an attachment only; no speech-to-text is performed in the LINE path | `crates/openab-gateway/src/adapters/line.rs:511-639` | +| trust_gate | implemented | L1 signature at ingress (HMAC-SHA256 over raw body vs `x-line-signature`); shared L2 scope / L3 identity trust gate applies in the gateway ingress path, keyed by platform | L1 `crates/openab-gateway/src/adapters/line.rs:84-105`; trust gate `crates/openab-core/src/gateway.rs:1196-1200` [PR #1291] | +| deny_echo | workaround | On L3 identity-deny the gateway echoes the sender their ID via `adapter.send_message` (throttled per `platform:sender`). On LINE that echo flows through the same hybrid `dispatch_line_reply` keyed by the original event's reply token, so it is **Reply in practice** (deny happens at ingress while the token is still fresh); note this is not a hard "never Push" guarantee — if the token were expired the shared dispatcher would still fall back to Push. 1:1 echo includes the UID; group/room echo carries no stable ID | echo + throttle `crates/openab-core/src/gateway.rs:1201-1226`; dispatcher `crates/openab-gateway/src/adapters/line.rs:646-730`; Reply-preferred intent [PR #1291] | +| mention_gating | implemented | In group/room events the adapter drops the message unless a mentionee has `isSelf=true` (the bot). 1:1 DMs always pass. No env var / bot-name matching needed — LINE flags self-mention | `crates/openab-gateway/src/adapters/line.rs:371-378` [PR #1291] | +| slash_commands | n/a | LINE has no slash-command surface; commands would arrive as plain text. `/reset`, `/cancel` handling is not wired in the LINE path (events are filtered to text/image/audio and forwarded as-is) | `crates/openab-gateway/src/adapters/line.rs:215-217` | +| multibot | n/a | LINE does not deliver other bots' messages, and inbound `is_bot` is hard-coded `false`, so multi-bot coordination cannot trigger on LINE | `crates/openab-gateway/src/adapters/line.rs:391` [PR #1291] | +| group_routing | implemented | Channel keyed by `groupId` (group) / `roomId` (room) / `userId` (1:1); a group/room missing its ID is skipped. Sender falls back to `"unknown"` when `userId` is absent | `crates/openab-gateway/src/adapters/line.rs:325-354` [PR #1291] | -| Aspect | Behavior | Official ref | -|---|---|---| -| L1 auth | Webhook body signed with HMAC-SHA256 over the raw body using the channel secret; sent in `X-Line-Signature` | [Signature validation](https://developers.line.biz/en/reference/messaging-api/#signature-validation) | -| Reply model | `replyToken` per webhook event — **single-use** and **short-lived** (must reply within ~1 min). Free, does not consume quota | [Send reply message](https://developers.line.biz/en/reference/messaging-api/#send-reply-message) | -| Push model | `POST /message/push` by `userId` — works without a reply token, but **consumes the monthly message quota** (paid beyond the free tier) | [Send push message](https://developers.line.biz/en/reference/messaging-api/#send-push-message) · [Pricing](https://developers.line.biz/en/docs/messaging-api/overview/) | -| Group identity | In group/room events, `source.userId` is **only present when the user consents to providing their info**; otherwise it is absent | [Source object](https://developers.line.biz/en/reference/messaging-api/#source-user) | -| Display name | **Not** included in webhooks. Must be fetched via Profile API (`GET /profile/{userId}`, group variant `GET /group/{groupId}/member/{userId}/profile`). Profile API resolves `userId → name`; it **cannot** recover a missing `userId` | [Get profile](https://developers.line.biz/en/reference/messaging-api/#get-profile) · [Group member profile](https://developers.line.biz/en/reference/messaging-api/#get-group-member-profile) | -| Bot-to-bot | LINE does not deliver other bots' messages to your webhook — no bot-message path | [Webhook events](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) | -| Mention | Group/room text events carry a `mention` object; the bot's own mentionee has `isSelf = true` (no bot-username env var needed) | [Message event / mention](https://developers.line.biz/en/reference/messaging-api/#wh-text) | +## 3. Platform quirks (`platform-quirks` v1) -## (B) OpenAB mapping +### Reply/Push hybrid dispatch (the core LINE model) -How the adapter (in the **gateway** crate) implements the above. Refs are `crates/openab-gateway/src/adapters/line.rs` unless noted. +LINE splits outbound sending into two APIs with different economics. Every inbound webhook carries a one-time `replyToken`; using it (`/message/reply`) is free but the token is short-lived (~1 minute, officially "may change without notice" and not guaranteed beyond one minute). Push (`/message/push`) works anytime but consumes the monthly quota. OpenAB caches the token at webhook receipt time (TTL tracked from true receipt, `REPLY_TOKEN_TTL_SECS = 50` in `crates/openab-gateway/src/lib.rs:17`, deliberately under LINE's ~60s), tries Reply first, and only falls back to Push when the token is missing/expired. The cache is bounded (`REPLY_TOKEN_CACHE_MAX = 10_000`, `crates/openab-gateway/src/lib.rs:20`) and swept periodically (`crates/openab-gateway/src/lib.rs:460-466`). -| Aspect | Implementation | Ref | -|---|---|---| -| L1 verify | HMAC-SHA256 over raw body vs `X-Line-Signature`; reject on missing/invalid | `line.rs:84-103` | -| Reply/Push dispatch | `dispatch_line_reply()` — hybrid: try Reply (cached token), fall back to Push when token expired/consumed. **Lives in the gateway crate**, not core | `line.rs:646` | -| Reply-token cache | `ReplyTokenCache`, TTL `REPLY_TOKEN_TTL_SECS = 50`, cap `REPLY_TOKEN_CACHE_MAX = 10_000` | `lib.rs:17`, `lib.rs:20` | -| Identity normalize | 1:1 → `channel_id = userId`; group → `group_id`; room → `room_id`. Sender `userId` falls back to `"unknown"` when absent | `line.rs:333-354` | -| SenderInfo | `id`/`name`/`display_name` all set to the raw `userId` (no name resolution today); `is_bot` hardcoded `false` | `line.rs:388-391` | -| @mention gating | Group/room messages that don't mention the bot are dropped **during normalization** (upstream of any trust check) | `line.rs:373-380` | -| Current trust | Shared gateway `should_skip_event()` in core — no LINE-specific trust today | `openab-core/src/gateway.rs:832` | - -## Trust / echo design (agreed for the ADR #1291 revision) - -- Trust decision in **core**; echo **delivery delegated to the gateway adapter** (LINE reuses `dispatch_line_reply`). -- deny-echo is **Reply-only, never Push** (no valid token → drop silently) to avoid push-quota DoS. -- Group config: `default_group_policy` + per-group `policy` (`open` = any member who @mentions; `members` = must be in `allowed_users`). `"unknown"` is always deny and never allowlistable. -- @mention gating stays **upstream** of the trust gate. -- Echo scope: 1:1 includes the sender UID; group/room carries **no ID** (generic message); both hard rate-limited. -- Name resolution enhancement: Profile API + local cache keyed by `userId` (long TTL; respects Profile API rate limit). +**Duplicate-safety bias:** on a Reply API error that is *not* a clearly-unusable-token 400 (e.g. network error, or a non-token 4xx/5xx), the dispatcher does **not** fall back to Push — it assumes the reply may have landed and returns `used_reply=true` to avoid double-sending (`crates/openab-gateway/src/adapters/line.rs:700-711`). Only an explicit "invalid … reply token" or "expired" 400 triggers Push fallback (`crates/openab-gateway/src/adapters/line.rs:697-699`). ---- +### Sender identity is best-effort, "unknown" is never trusted + +`userId` is optional in group/room sources (present only for LINE iOS/Android users). When absent, the sender collapses to the literal `"unknown"` (`crates/openab-gateway/src/adapters/line.rs:352-354`). Decision: `"unknown"` is **never allowlistable** — it cannot be added to `allowed_users`, because it is not a stable identity. Group admission for LINE is instead gated by self-mention (see below). [PR #1291] + +### @mention gating in groups/rooms + +In group/room events the adapter forwards the message only if some mentionee has `isSelf=true` — i.e. the bot itself was @-mentioned (`crates/openab-gateway/src/adapters/line.rs:372`). 1:1 DMs always pass. This relies entirely on LINE's `isSelf` flag, so no bot-name string matching or env var is needed. A group/room text with no `mention` object, or one where only other users are mentioned, is dropped. [PR #1291] + +### Early-ack webhook processing + +The webhook handler validates the signature, returns `200 OK`, then spawns background processing so slow image/audio downloads don't cause LINE to redeliver. Tradeoff (documented in-code at `crates/openab-gateway/src/adapters/line.rs:143-156`): once acked, a later crash is not retried by LINE, and cross-payload ordering can invert if an image event is slower than a following text event. A shared semaphore (`line_webhook_semaphore`, `LINE_WEBHOOK_CONCURRENCY_MAX`) bounds concurrent post-ack work to cap backlog under bursts; a saturated semaphore makes new webhooks wait before spawning (`crates/openab-gateway/src/adapters/line.rs:118-133`). + +### is_bot always false + +Inbound events hard-code `is_bot=false` (`crates/openab-gateway/src/adapters/line.rs:391`) because LINE never delivers other bots' messages to a bot. This means the shared multibot machinery is inert on LINE by construction. + +### External-content and unsupported media are surfaced, not silently dropped + +When an image/audio uses `contentProvider.type == "external"`, or the access token is unconfigured, the adapter emits an attachment with a `status` string (e.g. "unsupported format: external content not supported", "configuration error: service not configured") rather than dropping the event — so the agent still sees that media was present (image `crates/openab-gateway/src/adapters/line.rs:228-265`, audio `crates/openab-gateway/src/adapters/line.rs:270-316`). Video and generic files are filtered out earlier (only text/image/audio pass the type filter, `crates/openab-gateway/src/adapters/line.rs:215`). -## Findings log +### Findings log -Newest first. Type (A) → official-doc link; type (B) → PR/issue link. +- 2026-07-04 (A) No `reaction` webhook event in the documented Messaging API event list — bots can neither add/remove nor receive reactions; edit/delete endpoints also absent. Confirms reactions/edit/delete are n/a in OpenAB. [https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) +- 2026-07-04 (A) Reply token must be used within ~1 minute; LINE warns the limit may change without notice and use beyond one minute isn't guaranteed — motivates the conservative 50s TTL. Both Reply and Push accept up to 5 message objects per request. [https://developers.line.biz/en/docs/messaging-api/sending-messages/](https://developers.line.biz/en/docs/messaging-api/sending-messages/) +- 2026-07-04 (A) LINE text message objects cap at 5000 chars (counted in UTF-16 code units). [https://developers.line.biz/en/reference/messaging-api/#text-message](https://developers.line.biz/en/reference/messaging-api/#text-message) +- 2026-07-04 (A) Loading-animation ("typing") indicator is 1:1-only ("You can't specify group chats or multi-person chats"), rate-limited 100 req/s. [https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/](https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/) +- 2026-07-04 (A) `userId` is optional in group/room sources and only present for LINE iOS/Android users — confirms "unknown" sender is intrinsic, not a bug. [https://developers.line.biz/en/reference/messaging-api/#source-group](https://developers.line.biz/en/reference/messaging-api/#source-group) +- 2026-07-04 (A) Inbound media is fetched via get-content by message ID on the `api-data.line.me` host; `contentProvider.type` is `line` (fetchable) or `external` (URL only); user content auto-expires — fetch promptly. [https://developers.line.biz/en/reference/messaging-api/#get-content](https://developers.line.biz/en/reference/messaging-api/#get-content) +- 2026-07-04 (A) Push is metered by a plan-dependent monthly free-message quota; Reply API is free — the economic basis for the hybrid model. [https://developers.line.biz/en/docs/messaging-api/pricing/](https://developers.line.biz/en/docs/messaging-api/pricing/) +- 2026-07-04 (B) deny-echo on LINE reuses the hybrid `dispatch_line_reply` keyed by the original event token: Reply in practice (fresh token at ingress), but not a hard "never Push" guarantee — an expired token would fall back to Push like any reply. [PR #1291] +- 2026-07-04 (B) LINE adapter design: L1 HMAC-SHA256 at ingress, group two-mode admission via `isSelf` mention-gating, "unknown" never allowlistable, `is_bot` always false, early-ack + semaphore-bounded background processing, single text object per reply. [PR #1291] -- **2026-07-04** (B) deny-echo on LINE must be **Reply-only, never fall back to Push** — reply token dies in ~50s, so echoing denies to a spammer would mostly hit Push and burn the paid quota (DoS amplification). [PR #1291] -- **2026-07-04** (B) Trust decision in core, but echo **delivery** delegated to the gateway adapter — LINE's send path (`dispatch_line_reply`) lives in the gateway crate, so "core does the echo" doesn't hold. [PR #1291] -- **2026-07-04** (A/B) In groups, `allowed_users` is unreliable because `userId` may be absent (`"unknown"`). Two-mode group config (`open`/`members`); `"unknown"` is never allowlistable. [PR #1291] -- **2026-07-04** (B) @mention gating must stay **upstream** of the trust gate — downstream would deny-echo ordinary group chatter not addressed to the bot. [PR #1291] -- **2026-07-04** (A) Profile API resolves `userId → name` only; it cannot recover a missing `userId` (chicken-and-egg). Name resolution + local cache fixes readable names, not the genuine `"unknown"` case. [Get profile](https://developers.line.biz/en/reference/messaging-api/#get-profile) -- **2026-07-04** (A) `is_bot` is always false for LINE — no bot-to-bot webhook delivery, so bot-bypass trust semantics are a no-op here. [Webhook events](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) +**Open item (unknowable without running the platform):** the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. \ No newline at end of file From 3f36ef5264a25948d6e898417dfe2ef1ad53ecc3 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:19 +0000 Subject: [PATCH 11/37] docs(platforms): docs/platforms/teams.md (schema v1) --- docs/platforms/teams.md | 90 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/platforms/teams.md diff --git a/docs/platforms/teams.md b/docs/platforms/teams.md new file mode 100644 index 000000000..f809db588 --- /dev/null +++ b/docs/platforms/teams.md @@ -0,0 +1,90 @@ +--- +platform: teams +maintainer: "@TBD" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- + +# MS Teams — platform notes + +Engineering-facing capability & quirks reference for the MS Teams adapter. For operator setup see `docs/teams.md`. Follows the schemas in [`README.md`](./README.md). + +Teams is reached via the **Bot Framework / Azure Bot Connector**, not a direct Teams API. The OpenAB Teams adapter lives in the gateway (`crates/openab-gateway/src/adapters/teams.rs`) and speaks the Bot Framework REST activity protocol; core treats it as a generic gateway platform through `GatewayAdapter` (`crates/openab-core/src/gateway.rs`). The gateway's per-platform reply dispatch is wired in `crates/openab-gateway/src/lib.rs` (`teams::handle_reply` at lib.rs:616). + +## 1. Platform capability (`platform-capability` v1) + +| Field | Value | Source | +|---|---|---| +| transport | webhook — Bot Framework POSTs an `Activity` JSON to the bot's `/api/messages` messaging endpoint (one endpoint only). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| inbound_auth | 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. | [connector auth](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0) | +| threads | 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. | [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | +| slash_commands | 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. | [command menu](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) | +| mentions | `@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`. | [channel conversations · work with mentions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | +| emoji_reactions | Bot **receives** reaction events: yes, via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot **add/remove** own reactions: yes (SDK reaction APIs / connector). | [message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions) | +| edit_message | Yes — bot can update its own already-sent message: `PUT /v3/conversations/{conversationId}/activities/{activityId}`. Requires caching the activityId returned by the original post. | [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) | +| delete_message | Own messages only: `DELETE /v3/conversations/{conversationId}/activities/{activityId}`. A bot **cannot** update or delete messages sent by users. | [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) | +| rich_content | 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. | [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) · [suggested actions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| attachments | 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. | [build conversational · pictures](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| message_length_limit | ~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`. No fixed character count — a byte/UTF-16 budget, not a char cap. | [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) | +| dm_support | Yes — 1:1 personal chat (`conversationType: personal`). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| group_model | Taxonomy: `personal` (1:1), `groupChat` (group chat), `channel` (team channel, has reply chains + `channelData.team`/`channel`). Bot install scopes: `personal`, `groupChat`/`groupchat`, `team`. | [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | +| group_sender_identity | Yes — `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. | [channel conversations JSON payload](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | +| send_model | 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`). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| proactive_push | Yes, 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. | [rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) | +| bot_to_bot | No — Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | +| typing_indicator | Supported — bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter. | [connector API (typing activity)](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0) | + +## 2. OpenAB feature support (`openab-feature-support` v1) + +Teams uses the shared `GatewayAdapter` (a `ChatAdapter` impl) in core (`crates/openab-core/src/gateway.rs`); the actual platform I/O is the gateway-side `teams::handle_reply` → `send_activity` (`crates/openab-gateway/src/adapters/teams.rs`), dispatched from `crates/openab-gateway/src/lib.rs:616`. Several core-issued commands (`create_topic`, `edit_message`, `delete_message`, reactions) are **not dispatched** by the Teams gateway `handle_reply` — only reactions are explicitly short-circuited; the rest fall through to `send_activity` and would be mis-sent as plain messages. + +| Feature | Status | Note | Ref | +|---|---|---|---| +| send_message | implemented | Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`. | gateway.rs:231; teams.rs:562, `send_activity` def 329 | +| message_split/chunking | partial | 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. | adapter.rs:149; gateway.rs:473-474 | +| streaming | workaround | 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` (see below), so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code. | gateway.rs:701; teams.rs:372 (unused), 562 | +| reply/quote | not-implemented | `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. | gateway.rs:481-488, 249-263; teams.rs:591-604 | +| edit_message | not-implemented | 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. | gateway.rs:35, 585; teams.rs:562 | +| delete_message | not-implemented | Same as edit: 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. | gateway.rs:679; teams.rs:562 | +| emoji_reactions | not-implemented | `handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams. | gateway.rs:585; teams.rs:570-574 | +| threads/topics | not-implemented | 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. | gateway.rs:490; teams.rs:562 | +| media_inbound | not-implemented | 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`. | teams.rs:15-27, 488 | +| voice_stt | n/a | No voice-note ingestion path in the adapter; not applicable. | teams.rs:488 | +| trust_gate | implemented | 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`. | teams.rs:319-326, 482; gateway.rs:1198 | +| deny_echo | implemented | 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. | gateway.rs:1201-1226 | +| mention_gating | partial | 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. | gateway.rs:56-83; teams.rs:498-502, 537 | +| slash_commands | implemented | `/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. | gateway.rs:1004-1023 (WS), 1376-1389 (unified) | +| multibot | partial | 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. | gateway.rs:701; adapter.rs:415-421 | +| group_routing | implemented | 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. | teams.rs:493-544, 576-589; lib.rs:481-486 | + +## 3. Platform quirks (`platform-quirks` v1) + +### serviceUrl is per-conversation and must be cached/refreshed +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 (teams.rs:541-544) and refreshes the timestamp on every reply (teams.rs:581) to avoid TTL expiry mid-conversation; a background task in `lib.rs:481-486` evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies; teams.rs:517-520). + +### Sender identity: `from.id` vs `aadObjectId` +Verified: the adapter uses **`activity.from.id`** (the `29:1abc...` Bot Framework/Teams user id) as `SenderInfo.id` (teams.rs:504-508), **not** `from.aadObjectId`. `aadObjectId` (Entra object id, teams.rs:35) 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` (teams.rs:61-79) because Teams places it differently for personal vs channel webhooks (tests at teams.rs:740-777 pin this). + +### The reply/quote target is dropped +`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (gateway.rs:263, set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id, gateway.rs:249) and maps it to `replyToId` (teams.rs:591-604). 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 below. + +### Auth is heavier than most adapters (endorsements + serviceUrl claim) +JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the signing JWK **endorses** the activity's `channelId` (teams.rs:277-288) and (B1) that the token's `serviceurl` claim equals the activity's `serviceUrl` (teams.rs:303-313) — binding the token to the specific channel/service origin. JWKS keys are cached (1 h TTL, teams.rs:158) with a force-refresh-on-cache-miss path for Microsoft key rotation (teams.rs:240-271). 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 (teams.rs:424-467). + +### Write-side commands are largely unimplemented +`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop, teams.rs:570-574) — everything non-reaction is treated as a plain send (teams.rs:597-608). 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 at teams.rs:372). This is the main gap for a future PR. + +### Findings log +- 2026-07-04 (A) 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`. [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) +- 2026-07-04 (A) 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. [rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) +- 2026-07-04 (A) `suggestedActions` support `imBack` only, ≤6 buttons, one-on-one chats only, and not alongside attachments (any conversation type). [suggested actions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) +- 2026-07-04 (A) Outbound pictures ≤1024×1024 px, ≤1 MB, PNG/JPEG/GIF; animated GIF unsupported; Markdown inline image defaults to 256×256. [build conversational](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) +- 2026-07-04 (A) Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams. [message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions) +- 2026-07-04 (A) Bot can edit (`PUT .../activities/{activityId}`) and delete (`DELETE .../activities/{activityId}`) its own messages but never user messages. [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) +- 2026-07-04 (A) No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed. [command menu](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) +- 2026-07-04 (A) 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. [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) +- 2026-07-04 (B) Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter (teams.rs:504-508) forwards `from.id` as trust-gate key. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) +- 2026-07-04 (B) 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. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) \ No newline at end of file From 2c3c9df4b6d1d438c1cb0a026de991d852dfc6f8 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:09:20 +0000 Subject: [PATCH 12/37] docs(platforms): docs/platforms/README.md (schema v1) --- docs/platforms/README.md | 122 ++++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index f27f0ae2b..478fd3ae8 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -1,22 +1,112 @@ -# Messaging platforms — capability matrix +# Messaging platforms — schemas & index -Engineering-facing reference for how each messaging platform behaves and how OpenAB's adapters map it. **Distinct from** the operator setup guides in `docs/.md` — this tree is for maintainers/reviewers. +Engineering/reviewer-facing knowledge base for how each messaging platform behaves and how OpenAB maps it. **Distinct from** the operator setup guides in `docs/.md`. -Each platform has its own page with three parts: **(A) platform-intrinsic facts** (official-doc sourced), **(B) OpenAB mapping** (`file:line` + PR sourced), and a **findings log**. See [`line.md`](./line.md) for the worked example. +This README is **not a giant table** — it defines the **schemas** that every per-platform page follows. One page per platform (`line.md`, `slack.md`, …), each filled against the schemas below. See [`_template.md`](./_template.md) for a blank page. -**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — type (A) facts link the official platform doc; type (B) decisions link the PR/issue where the reasoning lives. +## How it works -## Matrix +Each platform page has three schema-driven sections: -| Aspect | LINE | Slack | Telegram | Feishu | WeCom | Google Chat | Teams | Discord | -|---|---|---|---|---|---|---|---|---| -| Transport | gateway | Socket Mode | gateway | unified/gateway | gateway | gateway | gateway | native WS | -| L1 auth | HMAC-SHA256 | app_token | secret_token + IP | SHA256 + encrypt key | Token sig + AES-256-CBC | JWT RS256 (JWKS) | JWT OIDC (JWKS) | bot_token | -| Reply/echo model | Reply(≈50s, free) / Push(quota) | chat API | send | send | send | send | send | send | -| Group sender ID | absent w/o consent → `unknown` | present | present | present | UserID | `users/...` | `activity.from.id` | present | -| Display name in event | ✗ (Profile API) | ✓ | ✓ | ✓ | ? | ? | ✓ | ✓ | -| Mention model | `mention.isSelf` | ? | bot_username | ? | ? | ? | ? | @mention + multibot | -| Bot-to-bot delivery | ✗ (`is_bot` always false) | ✓ | ✓ | ? | ? | ? | ? | ✓ (trusted_bot_ids) | -| Per-platform page | [line.md](./line.md) | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | _TODO_ | +1. **Platform capability** (fixed fields) — the platform's intrinsic nature and what a bot can/can't do inside it. Same fields for every platform. Source of truth = **official docs**. +2. **OpenAB feature support** (fixed fields) — for each OpenAB capability, whether this platform implements it, and how. Same fields for every platform. Source of truth = **our code (`file:line`) + the PR that decided it**. +3. **Platform quirks** (flexible) — anything that doesn't fit a fixed field (e.g. LINE's reply/push model). Free-form, plus a dated findings log. -Cells marked `?` / `TODO` are owned by that platform's maintainer — please fill from your adapter and official docs (don't guess; leave `?` if unverified). Only LINE is verified so far (against `crates/openab-gateway/src/adapters/line.rs`). +**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic facts link the **official platform doc**; OpenAB decisions/findings link the **PR/issue**. + +## Schema versioning + +Schemas evolve. Each schema carries a version; each platform page declares which versions it was written against, in front-matter: + +```yaml +--- +platform: line +maintainer: "@handle" +last_verified: 2026-07-04 +schema_versions: + platform-capability: v1 + openab-feature-support: v1 + platform-quirks: v1 +--- +``` + +**Current versions:** `platform-capability: v1` · `openab-feature-support: v1` · `platform-quirks: v1` + +The conformance table below shows each page's declared versions — a page lagging the current version is visible at a glance and needs an update pass. When a schema changes, bump its version here and update this table. + +### Conformance + +| Platform | capability | feature-support | quirks | last verified | +|---|---|---|---|---| +| [line](./line.md) | v1 | v1 | v1 | 2026-07-04 | +| [slack](./slack.md) | v1 | v1 | v1 | — | +| [telegram](./telegram.md) | v1 | v1 | v1 | — | +| [discord](./discord.md) | v1 | v1 | v1 | — | +| [feishu](./feishu.md) | v1 | v1 | v1 | — | +| [wecom](./wecom.md) | v1 | v1 | v1 | — | +| [googlechat](./googlechat.md) | v1 | v1 | v1 | — | +| [teams](./teams.md) | v1 | v1 | v1 | — | + +--- + +## Schema 1 — `platform-capability` (v1) + +Fixed fields. Every platform fills all of them. Each value carries an official-doc link where the fact isn't self-evident. Use `?` only when genuinely unverified (note what's missing). + +| Field | Meaning / allowed values | +|---|---| +| `transport` | how events arrive: webhook / websocket / socket-mode / long-poll | +| `inbound_auth` | L1 request-auth / signature scheme (e.g. HMAC-SHA256, JWT RS256, AES) | +| `threads` | native / reply-to-only / emulated / none — plus the model | +| `slash_commands` | supported? how registered / delivered? | +| `mentions` | how the bot detects being addressed (@mention, username, isSelf flag…) | +| `emoji_reactions` | can a bot **add** / **remove** reactions? does it **receive** reaction events? | +| `edit_message` | can a bot edit its own already-sent message? | +| `delete_message` | can a bot delete a message? (own / others) | +| `rich_content` | cards / buttons / markdown / rich-text support | +| `attachments` | inbound & outbound media types + size limits | +| `message_length_limit` | max chars per outbound message (chunking implication) | +| `dm_support` | 1:1 direct messages supported? | +| `group_model` | group / channel / room / space taxonomy | +| `group_sender_identity` | is a stable per-user sender id available in group events? consent-gated? | +| `send_model` | reply vs push; any reply window / token TTL | +| `proactive_push` | can the bot message unsolicited? quota / rate limits | +| `bot_to_bot` | does the platform deliver other bots' messages to this bot? | +| `typing_indicator` | supported? | + +## Schema 2 — `openab-feature-support` (v1) + +Fixed fields = the OpenAB capabilities exercised across adapters (derived from the `ChatAdapter` trait in `crates/openab-core/src/adapter.rs` + the trust/ingress layer). For each, give a **status** + note + `file:line` + PR ref. + +**Status enum:** `implemented` · `partial` · `workaround` · `not-implemented` · `n/a` (platform can't support it). +Always explain `workaround` / `partial` / `limited` — that "why" is the valuable part. + +| Feature | Notes to capture | +|---|---| +| `send_message` | basic outbound | +| `message_split/chunking` | long-message handling (`split_delivery`) | +| `streaming` | `stream_begin` / `stream_append` / `stream_finish` — live vs batched | +| `reply/quote` | `send_message_with_reply` | +| `edit_message` | own-message edit (`edit_message`) | +| `delete_message` | `delete_message` | +| `emoji_reactions` | `add_reaction` / `remove_reaction` | +| `threads/topics` | `create_thread` / `create_topic` | +| `media_inbound` | images / files / audio ingestion | +| `voice_stt` | speech-to-text on voice notes | +| `trust_gate` | allowlist / identity-trust enforcement point | +| `deny_echo` | reply-on-deny behavior + delivery constraints | +| `mention_gating` | require @mention in groups | +| `slash_commands` | `/reset`, `/cancel` handling | +| `multibot` | multiple bots in one channel | +| `group_routing` | group/session routing | + +## Schema 3 — `platform-quirks` (v1) + +Flexible. Anything not captured by Schema 1/2 — special models, gotchas, structural constraints. Two parts: + +- **Quirks** — free-form subsections (e.g. "Reply/Push model"). No fixed fields; whatever the platform needs. +- **Findings log** — dated entries, newest first, one line each. Tag `(A)` intrinsic → official-doc link; `(B)` OpenAB decision/finding → PR/issue link. + +``` +- YYYY-MM-DD (A|B) . [source link] +``` From 688215024bb2d8eb659677664536aa05e7d5e09a Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:21 +0000 Subject: [PATCH 13/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?wecom.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/wecom.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/wecom.md b/docs/platforms/wecom.md index da785fc02..eee86a8fa 100644 --- a/docs/platforms/wecom.md +++ b/docs/platforms/wecom.md @@ -1,20 +1,12 @@ ---- -platform: wecom -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # WeCom (企業微信) — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the WeCom adapter. For operator setup see `docs/wecom.md`. Follows the schemas in [`README.md`](./README.md). WeCom is integrated via the **self-built app (自建应用 / agentid) callback model**, not the newer "智能机器人 / 群机器人" model. The adapter (`crates/openab-gateway/src/adapters/wecom.rs`) receives a user's 1:1 message via an AES-encrypted callback and replies proactively via `/cgi-bin/message/send`. -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -37,7 +29,7 @@ WeCom is integrated via the **self-built app (自建应用 / agentid) callback m | `bot_to_bot` | n/a — self-built app callbacks originate from human members (`FromUserName` UserID); the platform does not deliver other apps'/bots' messages to a self-built app | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | | `typing_indicator` | Not supported by the API | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -58,7 +50,7 @@ WeCom is integrated via the **self-built app (自建应用 / agentid) callback m | `multibot` | n/a | Self-built app 1:1 callbacks come only from human members (`is_bot: false`); no other-bot delivery, no multi-bot channel | `wecom.rs:1067-1072` | | `group_routing` | partial | Sessions keyed by `wecom:{corp_id}:{from_user}` (per-user 1:1); no group routing since there are no group callbacks in this model | `wecom.rs:1059` | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### Send / push model (no reply window) From 417411cb8bd134ce242776f0883b4a3732b7fbd5 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:23 +0000 Subject: [PATCH 14/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?discord.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/discord.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/discord.md b/docs/platforms/discord.md index 70d0b7a9c..10c0ecec3 100644 --- a/docs/platforms/discord.md +++ b/docs/platforms/discord.md @@ -1,18 +1,10 @@ ---- -platform: discord -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # Discord — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the Discord adapter. For operator setup see `docs/discord.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the Discord adapter. For op | bot_to_bot | Yes — the gateway delivers other bots' messages; the `author.bot` flag distinguishes them (the bot's own messages arrive too and must be self-filtered). | [Gateway](https://docs.discord.com/developers/topics/gateway) | | typing_indicator | Supported — `POST /channels/{id}/typing` (Trigger Typing); inbound `TYPING_START` event. | [Gateway](https://docs.discord.com/developers/topics/gateway) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -56,7 +48,7 @@ Engineering-facing capability & quirks reference for the Discord adapter. For op | multibot | implemented | Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; `trusted_bot_ids` + @mention admits handoff regardless of `allow_bot_messages`. | `discord.rs:331`, `discord.rs:593`, `discord.rs:2947` | | group_routing | implemented | Per-thread dispatch keyed by `dispatcher.key("discord", channel_id, sender_id)`; thread↔parent allowlist via `detect_thread`; ambient mode buffers passive-channel messages. | `discord.rs:1066`, `discord.rs:2901`, `discord.rs:530` | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### Threads are channels A Discord thread has its own `channel_id`; the adapter resolves outbound targets via `thread_id.unwrap_or(channel_id)` (`resolve_channel`, `discord.rs:55`). Thread identity is `thread_metadata.is_some()` — `parent_id` alone is NOT reliable (category children also carry `parent_id`), so `detect_thread` returns early unless `has_thread_metadata`, and only uses `parent_id` for the allowlist check (`discord.rs:2901`). From 049271fda5578f85f6a97a1b10ab571f1f16aabb Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:24 +0000 Subject: [PATCH 15/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?=5Ftemplate.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/_template.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/_template.md b/docs/platforms/_template.md index 882a7db89..67168075a 100644 --- a/docs/platforms/_template.md +++ b/docs/platforms/_template.md @@ -1,18 +1,10 @@ ---- -platform: -maintainer: "@" -last_verified: -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the adapter. For operator setup see `docs/.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the adapter. For | bot_to_bot | | | | typing_indicator | | | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -56,7 +48,7 @@ Engineering-facing capability & quirks reference for the adapter. For | multibot | | | | | group_routing | | | | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### From 670b1e1e0171ab0af01d3f05031686963ad53aaa Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:26 +0000 Subject: [PATCH 16/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?telegram.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/telegram.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/telegram.md b/docs/platforms/telegram.md index 88bfaa235..a4010aa58 100644 --- a/docs/platforms/telegram.md +++ b/docs/platforms/telegram.md @@ -1,18 +1,10 @@ ---- -platform: telegram -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # Telegram — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the Telegram adapter. For operator setup see `docs/telegram.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the Telegram adapter. For o | bot_to_bot | per FAQ, "Bots will not be able to see messages from other bots regardless of mode." `is_bot` is present on `from` for the rare cases forwarded/quoted. | [Bot FAQ](https://core.telegram.org/bots/faq) | | typing_indicator | yes: `sendChatAction` (e.g. `typing`), auto-clears after ~5s or on next message. Not used by the OpenAB adapter. | [sendChatAction](https://core.telegram.org/bots/api#sendchataction) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) Telegram runs through the **gateway** path (webhook → `GatewayEvent`; `GatewayReply` → `handle_reply` command dispatch), not a `ChatAdapter` trait impl. So the `ChatAdapter` default-impl semantics (edit/delete/stream) do not directly apply; the gateway reply handler dispatches by `reply.command`. Note: the `MAX_DOWNLOAD` constants used below live in the **gateway** crate (`crates/openab-gateway/src/media.rs:13-15` → IMAGE 10 MB, FILE 20 MB, AUDIO 20 MB), while STT lives in **core**. @@ -58,7 +50,7 @@ Telegram runs through the **gateway** path (webhook → `GatewayEvent`; `Gateway | multibot | implemented | `should_skip_event` drops other bots' events unless the sender id is in `trusted_bot_ids`; the adapter forwards `is_bot` from `from`. (Telegram rarely delivers other bots' messages anyway.) | `crates/openab-core/src/gateway.rs:58`; `crates/openab-gateway/src/adapters/telegram.rs:217` | | group_routing | implemented | Session keyed by `chat.id` + optional `message_thread_id`; `compute_draft_id` derives a stable per-(chat,thread) draft id to avoid forum-topic collisions. | `crates/openab-gateway/src/adapters/telegram.rs:206`, `:339`, `:484` | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### "Rich Message" API is real (Bot API 10.1) — but the adapter path is flag-gated and partly dead-code The adapter's `sendRichMessage` / `sendRichMessageDraft` / `InputRichMessage.markdown` calls correspond to methods **genuinely added in Bot API 10.1 (2026-06-11)** ([api-changelog](https://core.telegram.org/bots/api-changelog)): "Added the method `sendRichMessage`…", "Added the method `sendRichMessageDraft`, allowing bots to stream partial rich messages", "Added the class `InputRichMessage`…". This corrects an earlier assumption that the path was fictional. Caveats that remain code-side, not API-side: From 71cfc1ef978c60b8e075b7df6d0d789136326c4d Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:27 +0000 Subject: [PATCH 17/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?googlechat.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/googlechat.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/googlechat.md b/docs/platforms/googlechat.md index 219ca5289..22f617ebc 100644 --- a/docs/platforms/googlechat.md +++ b/docs/platforms/googlechat.md @@ -1,18 +1,10 @@ ---- -platform: googlechat -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # Google Chat — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the Google Chat adapter. For operator setup see `docs/googlechat.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the Google Chat adapter. Fo | bot_to_bot | Not delivered — the `MESSAGE` interaction event is defined as "A user messages a Chat app"; other apps'/bots' messages are not surfaced to a Chat app. (No doc positively states delivery; treat as unsupported. Adapter also drops `sender.type == "BOT"` inbound.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | | typing_indicator | Not supported — Google Chat exposes no typing/composing API for apps; none of the documented app-facing interaction events or send surfaces include a typing signal. (Verified negative against the interactions surface, which enumerates the full set of app capabilities.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -56,7 +48,7 @@ Engineering-facing capability & quirks reference for the Google Chat adapter. Fo | multibot | partial | Bot senders are dropped inbound (`sender.user_type == "BOT"` → skip, `googlechat.rs:532`); core's multibot/other-bot streaming suppression can't observe other bots since Chat doesn't deliver their messages. `use_streaming` ignores `other_bot_present` for gateway adapters. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:532`; core `crates/openab-core/src/gateway.rs:701` | | group_routing | implemented | Session/route keyed on `space.name` + optional `thread_id`; ChannelInfo carries `id`=space, `channel_type`=space type, `thread_id`. | `crates/openab-gateway/src/adapters/googlechat.rs:697` | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### Auth asymmetry: two independent credential paths Inbound webhooks are authenticated by verifying Google's ID-token (`email==chat@system.gserviceaccount.com`, RS256 via JWKS, `iss=accounts.google.com`, audience-checked). Outbound API calls use a *separate* service-account → OAuth2 JWT-bearer exchange (`scope=https://www.googleapis.com/auth/chat.bot`), cached with a 300 s refresh margin. Missing outbound creds degrade to a logged dry-run that still acks failure to core. (`googlechat.rs:150,218,773,831,349`) From a73a2133d917e913ffc2196ae5e59568ae5acd55 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:29 +0000 Subject: [PATCH 18/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?feishu.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/feishu.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/feishu.md b/docs/platforms/feishu.md index b2242ccbc..8f72a1fc2 100644 --- a/docs/platforms/feishu.md +++ b/docs/platforms/feishu.md @@ -1,20 +1,12 @@ ---- -platform: feishu -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # Feishu / Lark — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the Feishu / Lark adapter. For operator setup see `docs/feishu.md`. Follows the schemas in [`README.md`](./README.md). The adapter lives in the **gateway** crate (`crates/openab-gateway/src/adapters/feishu.rs`), not in a core `ChatAdapter` impl. Core talks to a generic `GatewayAdapter` (`crates/openab-core/src/gateway.rs`) over a WebSocket reply/response protocol; the gateway process runs the actual Feishu API calls. `domain=feishu` → `open.feishu.cn`, `domain=lark` → `open.larksuite.com` (`api_base()` `feishu.rs:290`). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -37,7 +29,7 @@ The adapter lives in the **gateway** crate (`crates/openab-gateway/src/adapters/ | bot_to_bot | **Effectively no.** Feishu does **not** push another bot's messages to a bot's WebSocket, and marks other bots' messages as `sender_type="user"`, so a peer bot is indistinguishable without an explicit id allowlist. *(Platform docs are silent on bot→bot event delivery — behavior verified only via the adapter, not an official statement.)* | Adapter comment `feishu.rs:1158`, `parse_message_event` `feishu.rs:412` · [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | | typing_indicator | **Not exposed** to bots — no public "typing"/"inputting" API is documented (Message FAQ is silent on it). OpenAB signals progress with emoji reactions / a streaming card instead. *(Absence confirmed by omission; cannot be stated as an affirmative platform guarantee.)* | [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -58,7 +50,7 @@ The adapter lives in the **gateway** crate (`crates/openab-gateway/src/adapters/ | multibot | partial | `FEISHU_ALLOW_BOTS` (off/mentions/all) + `FEISHU_TRUSTED_BOT_IDS` + per-chat `max_bot_turns` (default 20) loop-guard. **Caveat:** Feishu marks other bots as `sender_type="user"` and doesn't push bot messages over WS, so without `trusted_bot_ids` peer bots can't be identified; `multibot_mentions` detection is done via **@mention of a known bot id**, not sender type. | `parse_message_event` `feishu.rs:417`, bot-turn guard `feishu.rs:1142`, `detect_and_mark_multibot` `feishu.rs:2408` | | group_routing | implemented | Routing keyed by `chat_id`; `channel_type` = `direct` (p2p) vs `group`; `thread_id` from `root_id` / `parent_id`. Dedupe by `event_id` + `message_id`; self-echo dedupe on sent `message_id` (Feishu pushes the bot's own messages back). | `parse_message_event` `feishu.rs:557`, dedupe `feishu.rs:1116` / `:1138` | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### 20-edit-per-message cap → streaming card promotion From 4c0d70afe80db6553d2bad93e023ca6de51c9e88 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:31 +0000 Subject: [PATCH 19/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?slack.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/slack.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/slack.md b/docs/platforms/slack.md index fb87683c3..76f6e9dc5 100644 --- a/docs/platforms/slack.md +++ b/docs/platforms/slack.md @@ -1,18 +1,10 @@ ---- -platform: slack -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # Slack — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the Slack adapter. For operator setup see `docs/slack.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the Slack adapter. For oper | bot_to_bot | Yes — other bots' messages are delivered as `message` events with `subtype: "bot_message"` and a `bot_id`/`bot_profile`; the receiving app must opt to process them. | [bot_message event](https://docs.slack.dev/reference/events/message/bot_message/) | | typing_indicator | Not used as a typing dot. Assistant mode instead surfaces an ephemeral status line via `assistant.threads.setStatus` ("Thinking…"). | [assistant.threads.setStatus](https://docs.slack.dev/reference/methods/assistant.threads.setStatus) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -56,7 +48,7 @@ Engineering-facing capability & quirks reference for the Slack adapter. For oper | multibot | implemented | Eager other-bot detection on inbound bot messages (`note_other_bot_in_thread`, slack.rs:134, invoked at slack.rs:901), persisted to a disk cache (irreversible). Disables streaming (`use_streaming`/`uses_native_streaming` gate on `other_bot_present`) and, with `MultibotMentions`, requires @mention. Consecutive-bot-turn cap (`MAX_CONSECUTIVE_BOT_TURNS` = 1000, enforced at slack.rs:980) + `BotTurnTracker` soft/hard limits guard loops. | slack.rs:134, slack.rs:901, slack.rs:980 | | group_routing | implemented | Routed through `Dispatcher`; key is grouping-dependent — `slack:` in `Thread` mode or `slack::` in `Lane` mode (`Dispatcher::key`, dispatch.rs:295), with `thread_id` falling back to `channel_id` outside a thread (slack.rs:1524). Sender context is serialized with the Slack-native `thread_ts` key (slack.rs:1491) so agents calling the API directly see the right field. | dispatch.rs:295, slack.rs:1524, slack.rs:1491 | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### Socket Mode keepalive (deaf-socket guard) Slack's inbound WebSocket can go half-open (NAT idle-timeout silently drops inbound frames with no Close/FIN), leaving `read.next()` blocked forever so the reconnect loop never fires — the bot appears connected but goes deaf. The adapter pings every 30s (`PING_INTERVAL_SECS`) and force-reconnects if no inbound frame — including Slack's own pings — arrives within 75s (`IDLE_TIMEOUT_SECS`). Backoff doubles to a 30s cap, mirroring the gateway: 1,2,4,8,16,30,30… (slack.rs:699, slack.rs:711). From c35226a5295a0de98e127f2297cbc82d5f2aa019 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:32 +0000 Subject: [PATCH 20/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?line.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/line.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/line.md b/docs/platforms/line.md index 0b232096d..8dbb3a2f3 100644 --- a/docs/platforms/line.md +++ b/docs/platforms/line.md @@ -1,18 +1,10 @@ ---- -platform: line -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # LINE — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the LINE adapter. For operator setup see `docs/line.md`. Follows the schemas in [`README.md`](./README.md). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -35,7 +27,7 @@ Engineering-facing capability & quirks reference for the LINE adapter. For opera | bot_to_bot | No — LINE Official Accounts (bots) do not receive messages from other bots; webhook message events are for user-originated content | [Messaging API overview](https://developers.line.biz/en/docs/messaging-api/overview/) | | typing_indicator | Yes — "Display a loading animation" endpoint, **1:1 chats only** ("You can't specify group chats or multi-person chats"), rate-limited to 100 req/s. It is a loading animation while the user is viewing the chat, not a per-keystroke typing indicator | [Display a loading indicator](https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) | Feature | Status | Note | Ref | |---|---|---|---| @@ -56,7 +48,7 @@ Engineering-facing capability & quirks reference for the LINE adapter. For opera | multibot | n/a | LINE does not deliver other bots' messages, and inbound `is_bot` is hard-coded `false`, so multi-bot coordination cannot trigger on LINE | `crates/openab-gateway/src/adapters/line.rs:391` [PR #1291] | | group_routing | implemented | Channel keyed by `groupId` (group) / `roomId` (room) / `userId` (1:1); a group/room missing its ID is skipped. Sender falls back to `"unknown"` when `userId` is absent | `crates/openab-gateway/src/adapters/line.rs:325-354` [PR #1291] | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### Reply/Push hybrid dispatch (the core LINE model) From cbbc245eae64e35caf0f113afb460de32a621684 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:34 +0000 Subject: [PATCH 21/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?teams.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/teams.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/platforms/teams.md b/docs/platforms/teams.md index f809db588..0a4f6b3e5 100644 --- a/docs/platforms/teams.md +++ b/docs/platforms/teams.md @@ -1,20 +1,12 @@ ---- -platform: teams -maintainer: "@TBD" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- - # MS Teams — platform notes +**Schema version:** 2026-07-04 + Engineering-facing capability & quirks reference for the MS Teams adapter. For operator setup see `docs/teams.md`. Follows the schemas in [`README.md`](./README.md). Teams is reached via the **Bot Framework / Azure Bot Connector**, not a direct Teams API. The OpenAB Teams adapter lives in the gateway (`crates/openab-gateway/src/adapters/teams.rs`) and speaks the Bot Framework REST activity protocol; core treats it as a generic gateway platform through `GatewayAdapter` (`crates/openab-core/src/gateway.rs`). The gateway's per-platform reply dispatch is wired in `crates/openab-gateway/src/lib.rs` (`teams::handle_reply` at lib.rs:616). -## 1. Platform capability (`platform-capability` v1) +## 1. Platform capability (`platform-capability`) | Field | Value | Source | |---|---|---| @@ -37,7 +29,7 @@ Teams is reached via the **Bot Framework / Azure Bot Connector**, not a direct T | bot_to_bot | No — Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | | typing_indicator | Supported — bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter. | [connector API (typing activity)](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0) | -## 2. OpenAB feature support (`openab-feature-support` v1) +## 2. OpenAB feature support (`openab-feature-support`) Teams uses the shared `GatewayAdapter` (a `ChatAdapter` impl) in core (`crates/openab-core/src/gateway.rs`); the actual platform I/O is the gateway-side `teams::handle_reply` → `send_activity` (`crates/openab-gateway/src/adapters/teams.rs`), dispatched from `crates/openab-gateway/src/lib.rs:616`. Several core-issued commands (`create_topic`, `edit_message`, `delete_message`, reactions) are **not dispatched** by the Teams gateway `handle_reply` — only reactions are explicitly short-circuited; the rest fall through to `send_activity` and would be mis-sent as plain messages. @@ -60,7 +52,7 @@ Teams uses the shared `GatewayAdapter` (a `ChatAdapter` impl) in core (`crates/o | multibot | partial | 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. | gateway.rs:701; adapter.rs:415-421 | | group_routing | implemented | 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. | teams.rs:493-544, 576-589; lib.rs:481-486 | -## 3. Platform quirks (`platform-quirks` v1) +## 3. Platform quirks (`platform-quirks`) ### serviceUrl is per-conversation and must be cached/refreshed 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 (teams.rs:541-544) and refreshes the timestamp on every reply (teams.rs:581) to avoid TTL expiry mid-conversation; a background task in `lib.rs:481-486` evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies; teams.rs:517-520). From 9a2ae6bbb2f899868187405ffe9163df1048884a Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Sat, 4 Jul 2026 18:27:35 +0000 Subject: [PATCH 22/37] =?UTF-8?q?docs(platforms):=20single=20date=20schema?= =?UTF-8?q?=20version,=20drop=20front-matter=20=E2=80=94=20docs/platforms/?= =?UTF-8?q?README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/platforms/README.md | 48 ++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index 478fd3ae8..4ff0989c3 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -14,42 +14,32 @@ Each platform page has three schema-driven sections: **Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic facts link the **official platform doc**; OpenAB decisions/findings link the **PR/issue**. -## Schema versioning +## Schema version -Schemas evolve. Each schema carries a version; each platform page declares which versions it was written against, in front-matter: +The three schemas share **one date-based version**. When any schema changes, bump the date here and re-verify the pages. -```yaml ---- -platform: line -maintainer: "@handle" -last_verified: 2026-07-04 -schema_versions: - platform-capability: v1 - openab-feature-support: v1 - platform-quirks: v1 ---- -``` +- **Current schema version: `2026-07-04`** +- Each platform page records the version it was written against as a single line near the top: `**Schema version:** 2026-07-04`. +- No per-page front-matter: the platform is the **filename**, page **ownership is tracked separately** (not in these pages), and last-touched is in **git history**. -**Current versions:** `platform-capability: v1` · `openab-feature-support: v1` · `platform-quirks: v1` - -The conformance table below shows each page's declared versions — a page lagging the current version is visible at a glance and needs an update pass. When a schema changes, bump its version here and update this table. +The conformance table below lists each page's version — a page older than the current version is stale and needs an update pass. ### Conformance -| Platform | capability | feature-support | quirks | last verified | -|---|---|---|---|---| -| [line](./line.md) | v1 | v1 | v1 | 2026-07-04 | -| [slack](./slack.md) | v1 | v1 | v1 | — | -| [telegram](./telegram.md) | v1 | v1 | v1 | — | -| [discord](./discord.md) | v1 | v1 | v1 | — | -| [feishu](./feishu.md) | v1 | v1 | v1 | — | -| [wecom](./wecom.md) | v1 | v1 | v1 | — | -| [googlechat](./googlechat.md) | v1 | v1 | v1 | — | -| [teams](./teams.md) | v1 | v1 | v1 | — | +| Platform | Schema version | +|---|---| +| [line](./line.md) | 2026-07-04 | +| [slack](./slack.md) | 2026-07-04 | +| [telegram](./telegram.md) | 2026-07-04 | +| [discord](./discord.md) | 2026-07-04 | +| [feishu](./feishu.md) | 2026-07-04 | +| [wecom](./wecom.md) | 2026-07-04 | +| [googlechat](./googlechat.md) | 2026-07-04 | +| [teams](./teams.md) | 2026-07-04 | --- -## Schema 1 — `platform-capability` (v1) +## Schema 1 — `platform-capability` Fixed fields. Every platform fills all of them. Each value carries an official-doc link where the fact isn't self-evident. Use `?` only when genuinely unverified (note what's missing). @@ -74,7 +64,7 @@ Fixed fields. Every platform fills all of them. Each value carries an official-d | `bot_to_bot` | does the platform deliver other bots' messages to this bot? | | `typing_indicator` | supported? | -## Schema 2 — `openab-feature-support` (v1) +## Schema 2 — `openab-feature-support` Fixed fields = the OpenAB capabilities exercised across adapters (derived from the `ChatAdapter` trait in `crates/openab-core/src/adapter.rs` + the trust/ingress layer). For each, give a **status** + note + `file:line` + PR ref. @@ -100,7 +90,7 @@ Always explain `workaround` / `partial` / `limited` — that "why" is the valuab | `multibot` | multiple bots in one channel | | `group_routing` | group/session routing | -## Schema 3 — `platform-quirks` (v1) +## Schema 3 — `platform-quirks` Flexible. Anything not captured by Schema 1/2 — special models, gotchas, structural constraints. Two parts: From d54890ee17cb37468350b8a5db46c54637fff357 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Tue, 7 Jul 2026 17:59:57 +0000 Subject: [PATCH 23/37] =?UTF-8?q?docs(platforms):=20add=20=5Ftemplate.toml?= =?UTF-8?q?=20=E2=80=94=20machine-readable=20schema=20(Option=201,=20serde?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotated TOML template that doubles as the human-readable schema reference. Covers all three schemas from README.md (platform-capability / openab-feature- support / platform-quirks) as typed fields + note + source, ready to be validated by serde structs + a cargo conformance test (issue #1322). Schema-only for now; per-platform schema/*.toml + the conformance harness follow once the field set is confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/_template.toml | 184 ++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/platforms/_template.toml diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml new file mode 100644 index 000000000..1e4e41719 --- /dev/null +++ b/docs/platforms/_template.toml @@ -0,0 +1,184 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — TEMPLATE & human-readable schema reference +# ═══════════════════════════════════════════════════════════════════════════ +# +# Copy this file to schema/.toml (filename = platform name, +# snake_case) and fill every field. +# +# This template IS the schema documentation. The authoritative *types* live in +# the Rust structs at `crates/.../platform_schema.rs` (serde). A conformance +# test deserializes this template + every schema/*.toml into those structs, so +# this file must always stay valid — if it drifts from the structs, CI fails. +# +# Three schemas, matching docs/platforms/README.md: +# [capability.*] -> Schema 1 platform-capability (source: official docs) +# [[openab_features]] -> Schema 2 openab-feature-support (source: our code + PR) +# [[quirks]] -> Schema 3 platform-quirks (freeform dated log) +# +# Sourcing rule: +# (A) intrinsic facts -> `source` is an official-doc URL +# (B) OpenAB decisions -> `source` is "crates/.../file.rs:line" (+ optional pr) +# +# Conventions: +# required = must be present, deserialize fails otherwise +# optional = may be omitted (Rust Option / #[serde(default)]) +# enum = closed set; any other value fails to deserialize +# Use "?" in a note (not as a value) when a fact is genuinely unverified. +# ═══════════════════════════════════════════════════════════════════════════ + +# Version this file was written against. Bumped when the schema changes. +# Conformance flags any schema/*.toml older than the current version as stale. +schema_version = "2026-07-07" # required, "YYYY-MM-DD" + +[platform] +name = "" # required. matches filename, snake_case +official_docs = "" # required. URL to the platform's official docs root +description = "" # required. one-line "what is this platform" + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ +# Every field below is required. Each carries a typed value (bool/enum/int) for +# machine comparison + a freeform `note` + an official-doc `source` URL. + +[capability.transport] +# enum: webhook | websocket | socket_mode | long_poll +kind = "webhook" # required +note = "" # required. how events arrive +source = "" # required. official-doc URL + +[capability.inbound_auth] +# enum: hmac_sha256 | jwt_rs256 | aes | shared_secret | oauth | none +scheme = "hmac_sha256" # required. L1 request-auth / signature scheme +note = "" # required. e.g. "HMAC over raw body vs x-line-signature" +source = "" # required + +[capability.threads] +# enum: native | reply_to_only | emulated | none +model = "none" # required +note = "" # required. the thread/topic model, if any +source = "" # required + +[capability.slash_commands] +supported = false # required, bool. platform-level command API? +note = "" # required. registration / delivery, or "plain text only" +source = "" # required + +[capability.mentions] +note = "" # required. how the bot detects being addressed +source = "" # required +# how the bot knows it was addressed. enum: at_mention | username | self_flag | none +method = "at_mention" # required + +[capability.emoji_reactions] +bot_can_add = false # required, bool +bot_can_remove = false # required, bool +bot_receives_events = false # required, bool. does the bot get reaction webhooks? +note = "" # required +source = "" # required + +[capability.edit_message] +supported = false # required, bool. can a bot edit its own sent message? +note = "" # required +source = "" # required + +[capability.delete_message] +supported = false # required, bool +# enum: none | own | others | own_and_others +scope = "none" # required. what a bot may delete +note = "" # required +source = "" # required + +[capability.rich_content] +markdown = false # required, bool +cards = false # required, bool. cards / templates / flex +buttons = false # required, bool. interactive buttons +note = "" # required. the concrete rich-message types +source = "" # required + +[capability.attachments] +inbound = [] # required. subset of: image audio video file (what CAN arrive) +outbound = [] # required. subset of: image audio video file +max_size_mb = 0 # optional. 0/omit = unspecified; note the reason +note = "" # required. fetch model, provider caveats, expiry +source = "" # required + +[capability.message_length_limit] +max_chars = 0 # required, int. hard cap per outbound text object +note = "" # required. counting unit (e.g. UTF-16 code units) +source = "" # required + +[capability.dm_support] +supported = false # required, bool. 1:1 direct messages? +note = "" # required +source = "" # required + +[capability.group_model] +kinds = [] # required. taxonomy, e.g. ["group","room","user"] +note = "" # required +source = "" # required + +[capability.group_sender_identity] +# enum: yes | no | consent_gated — is a stable per-user id available in group events? +stable_id = "no" # required +note = "" # required. when present/absent, consent gating +source = "" # required + +[capability.send_model] +# enum: any_time | reply_only | push_only | hybrid +model = "any_time" # required +reply_token_ttl_sec = 0 # optional, int. only if reply-token based (e.g. LINE ~60) +max_objects_per_send = 0 # optional, int. batch cap per request, if any +note = "" # required. reply-vs-push economics / windows +source = "" # required + +[capability.proactive_push] +supported = false # required, bool. can the bot message unsolicited? +# enum: unlimited | metered | none +quota_model = "none" # required +note = "" # required. quota / rate-limit specifics +source = "" # required + +[capability.bot_to_bot] +delivered = false # required, bool. does the platform deliver other bots' msgs? +note = "" # required +source = "" # required + +[capability.typing_indicator] +supported = false # required, bool +note = "" # required. scope / rate limits / semantics +source = "" # required + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ +# One [[openab_features]] block per OpenAB capability. The full expected feature +# set (conformance checks all are present): +# send_message · message_split · streaming · reply_quote · edit_message · +# delete_message · emoji_reactions · threads_topics · media_inbound · +# voice_stt · trust_gate · deny_echo · mention_gating · slash_commands · +# multibot · group_routing +# +# status enum: implemented | partial | workaround | not_implemented | n_a +# Always explain partial / workaround / limited — the "why" is the value. +# `source` is an array of "crates/.../file.rs:line" — conformance verifies each +# file+line still exists. `pr` is optional (the deciding PR/issue). + +[[openab_features]] +feature = "send_message" # required. one of the keys listed above +status = "implemented" # required, enum +note = "" # required. how it maps; explain any non-"implemented" +source = [] # required. ["crates/.../line.rs:646", ...] +pr = "" # optional. e.g. "#1291" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ +# One [[quirks]] block per gotcha / special model / structural constraint. +# `note` is prose and is NOT machine-checked. `source` and `refs` are optional; +# when present, conformance shell-checks them (URL/file:line exists, PR exists). + +[[quirks]] +date = "" # required, "YYYY-MM-DD" — when documented +title = "" # required. short headline +note = "" # required. freeform prose (may be multi-paragraph) +kind = "A" # optional, enum: A (intrinsic) | B (openab decision) +source = "" # optional. official-doc URL or "file.rs:line" +refs = [] # optional. ["#1291", ...] PR/ADR links From 4d51e771fa33bd1b5637684b3469f379147a06ff Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Tue, 7 Jul 2026 18:10:39 +0000 Subject: [PATCH 24/37] docs(platforms): enumerate all 16 openab_features in _template.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit List the complete closed feature set as blank blocks (send_message … group_routing) so the template doubles as a fill-in form, not just one example. Clarify conformance: real schema/*.toml fully deserialize; the template is checked for section/feature-key completeness. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/_template.toml | 132 ++++++++++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index 1e4e41719..35d021246 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -6,9 +6,10 @@ # snake_case) and fill every field. # # This template IS the schema documentation. The authoritative *types* live in -# the Rust structs at `crates/.../platform_schema.rs` (serde). A conformance -# test deserializes this template + every schema/*.toml into those structs, so -# this file must always stay valid — if it drifts from the structs, CI fails. +# the Rust structs at `crates/.../platform_schema.rs` (serde). Conformance +# fully deserializes every real schema/*.toml into those structs, and checks +# this template still enumerates every capability section + every feature key — +# so if the structs gain/rename a field, this template must be updated too. # # Three schemas, matching docs/platforms/README.md: # [capability.*] -> Schema 1 platform-capability (source: official docs) @@ -150,24 +151,127 @@ source = "" # required # ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ -# One [[openab_features]] block per OpenAB capability. The full expected feature -# set (conformance checks all are present): -# send_message · message_split · streaming · reply_quote · edit_message · -# delete_message · emoji_reactions · threads_topics · media_inbound · -# voice_stt · trust_gate · deny_echo · mention_gating · slash_commands · -# multibot · group_routing +# One [[openab_features]] block per OpenAB capability. All 16 blocks below are +# the complete, closed feature set (conformance checks every one is present and +# that no unknown feature key is used). Fill each for the platform. # # status enum: implemented | partial | workaround | not_implemented | n_a # Always explain partial / workaround / limited — the "why" is the value. # `source` is an array of "crates/.../file.rs:line" — conformance verifies each # file+line still exists. `pr` is optional (the deciding PR/issue). +# (status/note/source left blank here — this is a blank form to copy & fill.) [[openab_features]] -feature = "send_message" # required. one of the keys listed above -status = "implemented" # required, enum -note = "" # required. how it maps; explain any non-"implemented" -source = [] # required. ["crates/.../line.rs:646", ...] -pr = "" # optional. e.g. "#1291" +feature = "send_message" # basic outbound +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "message_split" # long-message handling (split_delivery / message_limit) +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "streaming" # stream_begin / stream_append / stream_finish — live vs batched +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "reply_quote" # send_message_with_reply +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "edit_message" # own-message edit +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "delete_message" # delete own / others +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" # add_reaction / remove_reaction +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "threads_topics" # create_thread / create_topic +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "media_inbound" # images / files / audio ingestion +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "voice_stt" # speech-to-text on voice notes +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "trust_gate" # allowlist / identity-trust enforcement point +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "deny_echo" # reply-on-deny behavior + delivery constraints +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "mention_gating" # require @mention in groups +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "slash_commands" # /reset, /cancel handling +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "multibot" # multiple bots in one channel +status = "" +note = "" +source = [] +pr = "" + +[[openab_features]] +feature = "group_routing" # group / session routing +status = "" +note = "" +source = [] +pr = "" # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ From 00912bac601e66a8bd265d6149790a247c036e80 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Tue, 7 Jul 2026 18:16:11 +0000 Subject: [PATCH 25/37] docs(platforms): source points to file (+optional #symbol), not line numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line numbers go stale on any edit above the target. Switch `source` to "file.rs" or "file.rs#symbol" — a symbol name is stable (churns only on rename/delete, exactly the drift worth catching) and greppable, so conformance can actually verify it rather than trivially checking a line exists. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/_template.toml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index 35d021246..5078b23d0 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -18,7 +18,12 @@ # # Sourcing rule: # (A) intrinsic facts -> `source` is an official-doc URL -# (B) OpenAB decisions -> `source` is "crates/.../file.rs:line" (+ optional pr) +# (B) OpenAB decisions -> `source` points to a file, NOT a line number: +# "crates/.../file.rs" (file only), or +# "crates/.../file.rs#symbol" (file + a fn/const name for precision) +# Line numbers are deliberately avoided — they go stale on any edit above. +# A symbol name is stable (only churns when renamed/deleted — exactly the +# drift we want to catch) and is greppable, so conformance can verify it. # # Conventions: # required = must be present, deserialize fails otherwise @@ -157,15 +162,16 @@ source = "" # required # # status enum: implemented | partial | workaround | not_implemented | n_a # Always explain partial / workaround / limited — the "why" is the value. -# `source` is an array of "crates/.../file.rs:line" — conformance verifies each -# file+line still exists. `pr` is optional (the deciding PR/issue). +# `source` is an array of "file.rs" or "file.rs#symbol" (no line numbers) — +# conformance verifies each file exists, and greps for the #symbol if given. +# `pr` is optional (the deciding PR/issue). # (status/note/source left blank here — this is a blank form to copy & fill.) [[openab_features]] feature = "send_message" # basic outbound status = "" note = "" -source = [] +source = [] # e.g. ["crates/.../line.rs#dispatch_line_reply"] pr = "" [[openab_features]] @@ -284,5 +290,5 @@ date = "" # required, "YYYY-MM-DD" — when documented title = "" # required. short headline note = "" # required. freeform prose (may be multi-paragraph) kind = "A" # optional, enum: A (intrinsic) | B (openab decision) -source = "" # optional. official-doc URL or "file.rs:line" +source = "" # optional. official-doc URL, or "file.rs" / "file.rs#symbol" refs = [] # optional. ["#1291", ...] PR/ADR links From 5c401b8cda55446b69e6691b3da9c4fade16cde5 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Tue, 7 Jul 2026 18:27:41 +0000 Subject: [PATCH 26/37] docs(platforms): tighten _template.toml per review - quirks.kind now required, named values (intrinsic | openab_decision) not A/B - mentions: put typed `method` first for consistent fill order - attachments: keep single headline max_size_mb, require per-type/plan detail in note - Schema 1: add explicit fill guidance (note=how, source=official URL, "?" in note for unverified facts) so agents fill correctly Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/_template.toml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index 5078b23d0..a06d3b953 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -45,6 +45,13 @@ description = "" # required. one-line "what is this platform" # ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ # Every field below is required. Each carries a typed value (bool/enum/int) for # machine comparison + a freeform `note` + an official-doc `source` URL. +# +# How to fill each block: +# - Set the typed value(s) to the platform's actual behavior. +# - `note`: the concrete platform-specific detail a reviewer needs (the "how"). +# - `source`: the official-doc URL that proves the fact (one canonical link). +# - If a fact is genuinely unverified, still set the typed value to the best +# known guess and flag it in `note` with a leading "?" so it's auditable. [capability.transport] # enum: webhook | websocket | socket_mode | long_poll @@ -70,10 +77,10 @@ note = "" # required. registration / delivery, or "plai source = "" # required [capability.mentions] -note = "" # required. how the bot detects being addressed -source = "" # required # how the bot knows it was addressed. enum: at_mention | username | self_flag | none method = "at_mention" # required +note = "" # required. the concrete detection mechanism (e.g. isSelf flag) +source = "" # required [capability.emoji_reactions] bot_can_add = false # required, bool @@ -104,8 +111,10 @@ source = "" # required [capability.attachments] inbound = [] # required. subset of: image audio video file (what CAN arrive) outbound = [] # required. subset of: image audio video file -max_size_mb = 0 # optional. 0/omit = unspecified; note the reason -note = "" # required. fetch model, provider caveats, expiry +max_size_mb = 0 # optional, int. headline cap; 0/omit = unspecified +# required. fetch model, provider caveats, expiry — AND per-type / per-plan size +# limits when they differ from the single headline max_size_mb above. +note = "" source = "" # required [capability.message_length_limit] @@ -289,6 +298,7 @@ pr = "" date = "" # required, "YYYY-MM-DD" — when documented title = "" # required. short headline note = "" # required. freeform prose (may be multi-paragraph) -kind = "A" # optional, enum: A (intrinsic) | B (openab decision) +# required, enum: intrinsic (a platform fact) | openab_decision (a choice/finding of ours) +kind = "intrinsic" source = "" # optional. official-doc URL, or "file.rs" / "file.rs#symbol" refs = [] # optional. ["#1291", ...] PR/ADR links From 2c9401fbcc3dc8bcc2eec03fd95b1ef6bd7ebdd4 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Tue, 7 Jul 2026 18:49:44 +0000 Subject: [PATCH 27/37] feat(platforms): machine-readable schema/*.toml + conformance tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the schema-driven side of the platform knowledge base (issue #1322): - docs/platforms/schema/{line,slack,telegram,discord,feishu,wecom, googlechat,teams}.toml — all 8 platforms converted from their md pages into the typed schema (18 capability sections, the 16-feature closed set, quirks). Every `source` is a "file.rs" / "file.rs#symbol" code-ref, verified against the tree. - crates/platform-schema — the validator + conformance tests: structural validation (required fields, closed enums, closed feature set, unknown-key rejection) + the anti-drift check that every code-ref source still exists (file present, #symbol greps). 3 unit + 5 conformance tests, all green. Uses toml_edit parse-only: no proc-macros, no build scripts, so it needs no C toolchain — runs on minimal CI images. - .github/workflows/platform-schema-conformance.yml — runs the checker on any change to the schema files, the template, or the checker. Excluded from the root workspace so it builds independently of the heavy adapter crates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/platform-schema-conformance.yml | 27 + Cargo.toml | 2 +- crates/platform-schema/.gitignore | 1 + crates/platform-schema/Cargo.lock | 64 +++ crates/platform-schema/Cargo.toml | 16 + crates/platform-schema/src/lib.rs | 481 ++++++++++++++++++ crates/platform-schema/tests/conformance.rs | 140 +++++ docs/platforms/schema/discord.toml | 346 +++++++++++++ docs/platforms/schema/feishu.toml | 372 ++++++++++++++ docs/platforms/schema/googlechat.toml | 359 +++++++++++++ docs/platforms/schema/line.toml | 383 ++++++++++++++ docs/platforms/schema/slack.toml | 328 ++++++++++++ docs/platforms/schema/teams.toml | 337 ++++++++++++ docs/platforms/schema/telegram.toml | 353 +++++++++++++ docs/platforms/schema/wecom.toml | 307 +++++++++++ 15 files changed, 3515 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/platform-schema-conformance.yml create mode 100644 crates/platform-schema/.gitignore create mode 100644 crates/platform-schema/Cargo.lock create mode 100644 crates/platform-schema/Cargo.toml create mode 100644 crates/platform-schema/src/lib.rs create mode 100644 crates/platform-schema/tests/conformance.rs create mode 100644 docs/platforms/schema/discord.toml create mode 100644 docs/platforms/schema/feishu.toml create mode 100644 docs/platforms/schema/googlechat.toml create mode 100644 docs/platforms/schema/line.toml create mode 100644 docs/platforms/schema/slack.toml create mode 100644 docs/platforms/schema/teams.toml create mode 100644 docs/platforms/schema/telegram.toml create mode 100644 docs/platforms/schema/wecom.toml diff --git a/.github/workflows/platform-schema-conformance.yml b/.github/workflows/platform-schema-conformance.yml new file mode 100644 index 000000000..07b509809 --- /dev/null +++ b/.github/workflows/platform-schema-conformance.yml @@ -0,0 +1,27 @@ +name: platform-schema conformance + +# Verify docs/platforms/schema/*.toml stays in sync with the code: structural +# schema validation + that every `source` code-ref still exists in the tree. +# Runs on any change to the schema files, the template, or the checker itself. +on: + pull_request: + paths: + - "docs/platforms/schema/**" + - "docs/platforms/_template.toml" + - "crates/platform-schema/**" + push: + branches: [main] + paths: + - "docs/platforms/schema/**" + - "docs/platforms/_template.toml" + - "crates/platform-schema/**" + +jobs: + conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + # Dependency-light (toml_edit parse-only: no proc-macros, no build + # scripts) so this is fast and needs no extra system packages. + - run: cargo test --manifest-path crates/platform-schema/Cargo.toml diff --git a/Cargo.toml b/Cargo.toml index 69b6f01ae..a85c694a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = ["crates/openab-core", "crates/openab-gateway"] -exclude = ["openab-agent"] +exclude = ["openab-agent", "crates/platform-schema"] [package] name = "openab" diff --git a/crates/platform-schema/.gitignore b/crates/platform-schema/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/crates/platform-schema/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/platform-schema/Cargo.lock b/crates/platform-schema/Cargo.lock new file mode 100644 index 000000000..020baaee8 --- /dev/null +++ b/crates/platform-schema/Cargo.lock @@ -0,0 +1,64 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "platform-schema" +version = "0.1.0" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/crates/platform-schema/Cargo.toml b/crates/platform-schema/Cargo.toml new file mode 100644 index 000000000..d9d10a84f --- /dev/null +++ b/crates/platform-schema/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "platform-schema" +version = "0.1.0" +edition = "2021" +publish = false +description = "Schema definition + conformance tests for docs/platforms/schema/*.toml" + +# Standalone crate (excluded from the root workspace) so it builds independently +# of the heavy adapter crates. Deliberately dependency-light: toml_edit's +# parse-only mode pulls no proc-macros and no build scripts, so `cargo test` +# needs no C toolchain — it runs anywhere, including minimal CI images. +# +# Run: cargo test (from this directory) + +[dependencies] +toml_edit = { version = "0.22", default-features = false, features = ["parse"] } diff --git a/crates/platform-schema/src/lib.rs b/crates/platform-schema/src/lib.rs new file mode 100644 index 000000000..9b6e487f0 --- /dev/null +++ b/crates/platform-schema/src/lib.rs @@ -0,0 +1,481 @@ +//! Schema definition + validation for `docs/platforms/schema/*.toml`. +//! +//! The blank template and human-readable field docs live in +//! `docs/platforms/_template.toml`; this crate is the machine-enforced side. +//! [`validate`] checks one parsed file against the schema (required fields, +//! closed enum sets, the closed feature set, unknown-key rejection), and +//! [`feature_code_refs`] / [`quirk_code_refs`] surface the `source` code-refs so +//! the conformance tests can prove they still exist in the tree. +//! +//! Parsing uses `toml_edit` in parse-only mode: no proc-macros, no build +//! scripts, so the whole crate compiles and tests with no C toolchain. + +use toml_edit::{DocumentMut, Item, Table}; + +/// Current schema version. Bump when the schema changes; stale files are flagged. +pub const SCHEMA_VERSION: &str = "2026-07-07"; + +/// The complete, closed OpenAB feature set (Schema 2). Every platform file must +/// contain exactly these keys, once each. +pub const EXPECTED_FEATURES: &[&str] = &[ + "send_message", + "message_split", + "streaming", + "reply_quote", + "edit_message", + "delete_message", + "emoji_reactions", + "threads_topics", + "media_inbound", + "voice_stt", + "trust_gate", + "deny_echo", + "mention_gating", + "slash_commands", + "multibot", + "group_routing", +]; + +/// The allowed `status` values for an OpenAB feature. +pub const FEATURE_STATUS: &[&str] = &[ + "implemented", + "partial", + "workaround", + "not_implemented", + "n_a", +]; + +/// Statuses that claim the feature is present, so they must cite a source. +const STATUS_NEEDS_SOURCE: &[&str] = &["implemented", "partial", "workaround"]; + +// ─── capability section specs ─────────────────────────────────────────────── +// Each field: (name, kind). Kind drives the type/enum check. Every capability +// section also implicitly requires `note` (string) + `source` (string), added +// automatically, so specs below list only the section-specific fields. + +enum Kind { + Bool, + Uint, + OptUint, + StrArray, + /// String value constrained to a closed set. + Enum(&'static [&'static str]), + /// Array whose every element is constrained to a closed set. + EnumArray(&'static [&'static str]), +} + +struct Spec { + section: &'static str, + fields: &'static [(&'static str, Kind)], +} + +const TRANSPORT: &[&str] = &["webhook", "websocket", "socket_mode", "long_poll"]; +const AUTH: &[&str] = &[ + "hmac_sha256", + "jwt_rs256", + "aes", + "shared_secret", + "oauth", + "none", +]; +const THREADS: &[&str] = &["native", "reply_to_only", "emulated", "none"]; +const MENTION: &[&str] = &["at_mention", "username", "self_flag", "none"]; +const DELETE_SCOPE: &[&str] = &["none", "own", "others", "own_and_others"]; +const ATTACH: &[&str] = &["image", "audio", "video", "file"]; +const STABLE_ID: &[&str] = &["yes", "no", "consent_gated"]; +const SEND_MODEL: &[&str] = &["any_time", "reply_only", "push_only", "hybrid"]; +const QUOTA: &[&str] = &["unlimited", "metered", "none"]; + +/// Every `[capability.*]` sub-section in template order. +pub const CAPABILITY_SECTIONS: &[&str] = &[ + "transport", + "inbound_auth", + "threads", + "slash_commands", + "mentions", + "emoji_reactions", + "edit_message", + "delete_message", + "rich_content", + "attachments", + "message_length_limit", + "dm_support", + "group_model", + "group_sender_identity", + "send_model", + "proactive_push", + "bot_to_bot", + "typing_indicator", +]; + +fn capability_specs() -> &'static [Spec] { + use Kind::*; + &[ + Spec { section: "transport", fields: &[("kind", Enum(TRANSPORT))] }, + Spec { section: "inbound_auth", fields: &[("scheme", Enum(AUTH))] }, + Spec { section: "threads", fields: &[("model", Enum(THREADS))] }, + Spec { section: "slash_commands", fields: &[("supported", Bool)] }, + Spec { section: "mentions", fields: &[("method", Enum(MENTION))] }, + Spec { + section: "emoji_reactions", + fields: &[ + ("bot_can_add", Bool), + ("bot_can_remove", Bool), + ("bot_receives_events", Bool), + ], + }, + Spec { section: "edit_message", fields: &[("supported", Bool)] }, + Spec { + section: "delete_message", + fields: &[("supported", Bool), ("scope", Enum(DELETE_SCOPE))], + }, + Spec { + section: "rich_content", + fields: &[("markdown", Bool), ("cards", Bool), ("buttons", Bool)], + }, + Spec { + section: "attachments", + fields: &[ + ("inbound", EnumArray(ATTACH)), + ("outbound", EnumArray(ATTACH)), + ("max_size_mb", OptUint), + ], + }, + Spec { section: "message_length_limit", fields: &[("max_chars", Uint)] }, + Spec { section: "dm_support", fields: &[("supported", Bool)] }, + Spec { section: "group_model", fields: &[("kinds", StrArray)] }, + Spec { section: "group_sender_identity", fields: &[("stable_id", Enum(STABLE_ID))] }, + Spec { + section: "send_model", + fields: &[ + ("model", Enum(SEND_MODEL)), + ("reply_token_ttl_sec", OptUint), + ("max_objects_per_send", OptUint), + ], + }, + Spec { + section: "proactive_push", + fields: &[("supported", Bool), ("quota_model", Enum(QUOTA))], + }, + Spec { section: "bot_to_bot", fields: &[("delivered", Bool)] }, + Spec { section: "typing_indicator", fields: &[("supported", Bool)] }, + ] +} + +// ─── validation ───────────────────────────────────────────────────────────── + +/// Validate one parsed schema file (named `name`, e.g. "line") against the +/// schema. Returns a list of human-readable errors; empty means conforming. +pub fn validate(doc: &DocumentMut, name: &str) -> Vec { + let mut e = Vec::new(); + let root = doc.as_table(); + + // top-level: schema_version + platform + capability + arrays + check_unknown_keys( + root, + &["schema_version", "platform", "capability", "openab_features", "quirks"], + "(top level)", + &mut e, + ); + + match root.get("schema_version").and_then(Item::as_str) { + Some(v) if v == SCHEMA_VERSION => {} + Some(v) => e.push(format!("schema_version is {v:?}, expected {SCHEMA_VERSION:?} (stale)")), + None => e.push("missing schema_version (string)".into()), + } + + validate_platform(root, name, &mut e); + validate_capability(root, &mut e); + validate_features(root, &mut e); + validate_quirks(root, &mut e); + e +} + +fn validate_platform(root: &Table, name: &str, e: &mut Vec) { + let Some(t) = root.get("platform").and_then(Item::as_table) else { + e.push("missing [platform] table".into()); + return; + }; + check_unknown_keys(t, &["name", "official_docs", "description"], "[platform]", e); + req_str(t, "name", "[platform]", e); + req_str(t, "official_docs", "[platform]", e); + req_str(t, "description", "[platform]", e); + if let Some(n) = t.get("name").and_then(Item::as_str) { + if n != name { + e.push(format!("[platform].name is {n:?} but must match filename {name:?}")); + } + } +} + +fn validate_capability(root: &Table, e: &mut Vec) { + let Some(cap) = root.get("capability").and_then(Item::as_table) else { + e.push("missing [capability] table".into()); + return; + }; + let known: Vec<&str> = CAPABILITY_SECTIONS.to_vec(); + check_unknown_keys(cap, &known, "[capability]", e); + + for spec in capability_specs() { + let ctx = format!("[capability.{}]", spec.section); + let Some(t) = cap.get(spec.section).and_then(Item::as_table) else { + e.push(format!("missing {ctx}")); + continue; + }; + // allowed keys = spec fields + note + source + let mut allowed: Vec<&str> = spec.fields.iter().map(|(n, _)| *n).collect(); + allowed.push("note"); + allowed.push("source"); + check_unknown_keys(t, &allowed, &ctx, e); + + for (field, kind) in spec.fields { + check_field(t, field, kind, &ctx, e); + } + req_str(t, "note", &ctx, e); + req_str(t, "source", &ctx, e); + } +} + +fn validate_features(root: &Table, e: &mut Vec) { + let Some(arr) = root.get("openab_features").and_then(Item::as_array_of_tables) else { + e.push("missing [[openab_features]] (must have all 16)".into()); + return; + }; + let mut seen: Vec = Vec::new(); + for t in arr.iter() { + let ctx = "[[openab_features]]"; + check_unknown_keys(t, &["feature", "status", "note", "source", "pr"], ctx, e); + let feat = t.get("feature").and_then(Item::as_str); + match feat { + Some(f) if EXPECTED_FEATURES.contains(&f) => { + if seen.iter().any(|s| s == f) { + e.push(format!("duplicate feature {f:?}")); + } + seen.push(f.to_string()); + } + Some(f) => e.push(format!("unknown feature key {f:?}")), + None => e.push("feature block missing `feature` (string)".into()), + } + let fctx = feat.map(|f| format!("feature {f:?}")).unwrap_or_else(|| ctx.into()); + + let status = t.get("status").and_then(Item::as_str); + match status { + Some(s) if FEATURE_STATUS.contains(&s) => {} + Some(s) => e.push(format!("{fctx}: invalid status {s:?}")), + None => e.push(format!("{fctx}: missing status")), + } + req_str(t, "note", &fctx, e); + // source: array of strings (code refs) + let srcs = str_array(t, "source"); + if srcs.is_none() { + e.push(format!("{fctx}: `source` must be an array of strings")); + } + if let (Some(s), Some(list)) = (status, &srcs) { + if STATUS_NEEDS_SOURCE.contains(&s) && list.is_empty() { + e.push(format!("{fctx}: status {s:?} must cite at least one source")); + } + } + // pr optional string + if let Some(pr) = t.get("pr") { + if !pr.is_none() && pr.as_str().is_none() { + e.push(format!("{fctx}: `pr` must be a string")); + } + } + } + // closed-set completeness + for want in EXPECTED_FEATURES { + if !seen.iter().any(|s| s == want) { + e.push(format!("missing feature block {want:?}")); + } + } +} + +fn validate_quirks(root: &Table, e: &mut Vec) { + // quirks optional as a whole, but if present each block is validated. + let Some(item) = root.get("quirks") else { return }; + let Some(arr) = item.as_array_of_tables() else { + e.push("[[quirks]] must be an array of tables".into()); + return; + }; + for t in arr.iter() { + let title = t.get("title").and_then(Item::as_str).unwrap_or(""); + let ctx = format!("quirk {title:?}"); + check_unknown_keys(t, &["date", "title", "note", "kind", "source", "refs"], &ctx, e); + req_str(t, "date", &ctx, e); + req_str(t, "title", &ctx, e); + req_str(t, "note", &ctx, e); + match t.get("kind").and_then(Item::as_str) { + Some(k) if k == "intrinsic" || k == "openab_decision" => {} + Some(k) => e.push(format!("{ctx}: invalid kind {k:?} (intrinsic|openab_decision)")), + None => e.push(format!("{ctx}: missing kind")), + } + if let Some(src) = t.get("source") { + if !src.is_none() && src.as_str().is_none() { + e.push(format!("{ctx}: `source` must be a string")); + } + } + if let Some(r) = t.get("refs") { + if !r.is_none() && str_array(t, "refs").is_none() { + e.push(format!("{ctx}: `refs` must be an array of strings")); + } + } + } +} + +// ─── code-ref extraction (for the existence tests) ────────────────────────── + +/// (context, ref) for every code-ref in `[[openab_features]].source`. +pub fn feature_code_refs(doc: &DocumentMut) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(arr) = doc.as_table().get("openab_features").and_then(Item::as_array_of_tables) { + for t in arr.iter() { + let feat = t.get("feature").and_then(Item::as_str).unwrap_or("?"); + for s in str_array(t, "source").unwrap_or_default() { + out.push((format!("feature {feat:?}"), s)); + } + } + } + out +} + +/// (context, ref) for every code-ref in a quirk `source` (URLs skipped). +pub fn quirk_code_refs(doc: &DocumentMut) -> Vec<(String, String)> { + let mut out = Vec::new(); + if let Some(arr) = doc.as_table().get("quirks").and_then(Item::as_array_of_tables) { + for t in arr.iter() { + let title = t.get("title").and_then(Item::as_str).unwrap_or("?"); + if let Some(s) = t.get("source").and_then(Item::as_str) { + if is_code_ref(s) { + out.push((format!("quirk {title:?}"), s.to_string())); + } + } + } + } + out +} + +/// A parsed code-ref source: `"path/to/file.rs"` or `"path/to/file.rs#symbol"`. +pub struct CodeRef<'a> { + pub file: &'a str, + pub symbol: Option<&'a str>, +} + +pub fn parse_code_ref(s: &str) -> CodeRef<'_> { + match s.split_once('#') { + Some((file, symbol)) => CodeRef { file, symbol: Some(symbol) }, + None => CodeRef { file: s, symbol: None }, + } +} + +/// Does this source look like an in-repo code ref (vs an official-doc URL)? +pub fn is_code_ref(s: &str) -> bool { + !s.starts_with("http://") && !s.starts_with("https://") +} + +// ─── small typed helpers ──────────────────────────────────────────────────── + +fn check_field(t: &Table, field: &str, kind: &Kind, ctx: &str, e: &mut Vec) { + match kind { + Kind::Bool => { + if t.get(field).and_then(Item::as_bool).is_none() { + e.push(format!("{ctx}: `{field}` must be a bool")); + } + } + Kind::Uint => match t.get(field).and_then(Item::as_integer) { + Some(n) if n >= 0 => {} + _ => e.push(format!("{ctx}: `{field}` must be a non-negative integer")), + }, + Kind::OptUint => { + if let Some(item) = t.get(field) { + if !item.is_none() { + match item.as_integer() { + Some(n) if n >= 0 => {} + _ => e.push(format!("{ctx}: `{field}` must be a non-negative integer")), + } + } + } + } + Kind::StrArray => { + if str_array(t, field).is_none() { + e.push(format!("{ctx}: `{field}` must be an array of strings")); + } + } + Kind::Enum(allowed) => match t.get(field).and_then(Item::as_str) { + Some(v) if allowed.contains(&v) => {} + Some(v) => e.push(format!("{ctx}: `{field}` = {v:?} not in {allowed:?}")), + None => e.push(format!("{ctx}: `{field}` must be one of {allowed:?}")), + }, + Kind::EnumArray(allowed) => match str_array(t, field) { + None => e.push(format!("{ctx}: `{field}` must be an array of strings")), + Some(list) => { + for v in list { + if !allowed.contains(&v.as_str()) { + e.push(format!("{ctx}: `{field}` element {v:?} not in {allowed:?}")); + } + } + } + }, + } +} + +fn req_str(t: &Table, key: &str, ctx: &str, e: &mut Vec) { + if t.get(key).and_then(Item::as_str).is_none() { + e.push(format!("{ctx}: `{key}` must be a string")); + } +} + +fn str_array(t: &Table, key: &str) -> Option> { + let arr = t.get(key)?.as_array()?; + let mut out = Vec::new(); + for v in arr.iter() { + out.push(v.as_str()?.to_string()); + } + Some(out) +} + +fn check_unknown_keys(t: &Table, allowed: &[&str], ctx: &str, e: &mut Vec) { + for (k, _) in t.iter() { + if !allowed.contains(&k) { + e.push(format!("{ctx}: unknown key `{k}`")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_ref_parsing() { + let r = parse_code_ref("crates/a/src/b.rs#foo"); + assert_eq!(r.file, "crates/a/src/b.rs"); + assert_eq!(r.symbol, Some("foo")); + let r = parse_code_ref("crates/a/src/b.rs"); + assert_eq!(r.symbol, None); + assert!(is_code_ref("crates/a.rs")); + assert!(!is_code_ref("https://example.com")); + } + + #[test] + fn validate_flags_a_broken_file() { + // An empty doc should trip many required-field errors — proves the + // checker isn't vacuously passing. + let doc: DocumentMut = "schema_version = \"1999-01-01\"".parse().unwrap(); + let errs = validate(&doc, "line"); + assert!(!errs.is_empty()); + assert!(errs.iter().any(|e| e.contains("stale")), "should flag stale version"); + assert!(errs.iter().any(|e| e.contains("[platform]")), "should flag missing platform"); + assert!(errs.iter().any(|e| e.contains("openab_features")), "should flag missing features"); + } + + #[test] + fn validate_flags_a_bad_enum() { + let doc: DocumentMut = "[capability.transport]\nkind = \"carrier_pigeon\"\nnote = \"x\"\nsource = \"y\"" + .parse() + .unwrap(); + let errs = validate(&doc, "line"); + assert!( + errs.iter().any(|e| e.contains("carrier_pigeon")), + "should reject an out-of-set enum value" + ); + } +} diff --git a/crates/platform-schema/tests/conformance.rs b/crates/platform-schema/tests/conformance.rs new file mode 100644 index 000000000..160ac4526 --- /dev/null +++ b/crates/platform-schema/tests/conformance.rs @@ -0,0 +1,140 @@ +//! Conformance tests for `docs/platforms/schema/*.toml`. +//! +//! Run against the real repo tree: every platform file is parsed + validated +//! against the schema, and every code-ref `source` is checked to still point at +//! a file (and symbol) that exists — so the docs can't silently drift. + +use platform_schema::*; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use toml_edit::DocumentMut; + +/// The 8 platforms that must have a schema file. +const EXPECTED_PLATFORMS: &[&str] = &[ + "line", "slack", "telegram", "discord", "feishu", "wecom", "googlechat", "teams", +]; + +fn repo_root() -> PathBuf { + // CARGO_MANIFEST_DIR = /crates/platform-schema -> up 2 = + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("repo root") + .to_path_buf() +} + +fn schema_dir() -> PathBuf { + repo_root().join("docs/platforms/schema") +} + +/// Parse every schema/*.toml. A syntax error panics here (that's the parse check). +fn load_all() -> Vec<(String, DocumentMut)> { + let dir = schema_dir(); + let mut out = Vec::new(); + let entries = + fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); + for entry in entries { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("toml") { + continue; + } + let name = path.file_stem().unwrap().to_string_lossy().into_owned(); + let text = fs::read_to_string(&path).unwrap(); + let doc: DocumentMut = text + .parse() + .unwrap_or_else(|e| panic!("{} is not valid TOML: {e}", path.display())); + out.push((name, doc)); + } + out +} + +#[test] +fn all_expected_platform_files_present() { + let present: BTreeSet = load_all().into_iter().map(|(n, _)| n).collect(); + let missing: Vec<_> = EXPECTED_PLATFORMS + .iter() + .filter(|p| !present.contains(**p)) + .collect(); + assert!(missing.is_empty(), "missing schema files for: {missing:?}"); +} + +#[test] +fn every_file_conforms_to_schema() { + let mut all_errors = Vec::new(); + for (name, doc) in load_all() { + for err in validate(&doc, &name) { + all_errors.push(format!("{name}.toml: {err}")); + } + } + assert!( + all_errors.is_empty(), + "schema violations:\n {}", + all_errors.join("\n ") + ); +} + +/// The core anti-drift check: every feature code-ref points at a real file, and +/// every `#symbol` actually appears in it. +#[test] +fn feature_sources_exist_in_tree() { + let root = repo_root(); + let mut errs = Vec::new(); + for (name, doc) in load_all() { + for (ctx, src) in feature_code_refs(&doc) { + if !is_code_ref(&src) { + errs.push(format!("{name}.toml {ctx}: source {src:?} is a URL, expected a file ref")); + continue; + } + if let Err(msg) = check_code_ref(&root, &src) { + errs.push(format!("{name}.toml {ctx}: {msg}")); + } + } + } + assert!(errs.is_empty(), "dead feature sources:\n {}", errs.join("\n ")); +} + +#[test] +fn quirk_code_sources_exist_in_tree() { + let root = repo_root(); + let mut errs = Vec::new(); + for (name, doc) in load_all() { + for (ctx, src) in quirk_code_refs(&doc) { + if let Err(msg) = check_code_ref(&root, &src) { + errs.push(format!("{name}.toml {ctx}: {msg}")); + } + } + } + assert!(errs.is_empty(), "dead quirk sources:\n {}", errs.join("\n ")); +} + +fn check_code_ref(root: &Path, src: &str) -> Result<(), String> { + let r = parse_code_ref(src); + let path = root.join(r.file); + if !path.is_file() { + return Err(format!("source file {:?} does not exist", r.file)); + } + if let Some(sym) = r.symbol { + let text = fs::read_to_string(&path).map_err(|e| e.to_string())?; + if !text.contains(sym) { + return Err(format!("symbol {sym:?} not found in {:?} (renamed/deleted?)", r.file)); + } + } + Ok(()) +} + +/// The template must keep enumerating every capability section + feature key, so +/// a schema change can't silently leave the human-facing template behind. +#[test] +fn template_enumerates_every_section_and_feature() { + let text = fs::read_to_string(repo_root().join("docs/platforms/_template.toml")) + .expect("read _template.toml"); + for section in CAPABILITY_SECTIONS { + let header = format!("[capability.{section}]"); + assert!(text.contains(&header), "_template.toml missing {header}"); + } + for feature in EXPECTED_FEATURES { + let key = format!("feature = \"{feature}\""); + assert!(text.contains(&key), "_template.toml missing feature block for {feature}"); + } +} diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml new file mode 100644 index 000000000..5aacf43f3 --- /dev/null +++ b/docs/platforms/schema/discord.toml @@ -0,0 +1,346 @@ +# Discord platform capability schema. +# Generated from docs/platforms/discord.md — follows _template.toml. + +schema_version = "2026-07-07" + +[platform] +name = "discord" +official_docs = "https://docs.discord.com/developers/docs/intro" +description = "Discord bot adapter over the persistent WebSocket Gateway + REST HTTP API (serenity-based, in openab-core)." + + +# ═══ Schema 1 — platform-capability (source: official docs) ══════════════════ + +[capability.transport] +kind = "websocket" +note = "Persistent WSS Gateway (wss://gateway.discord.gg/): the bot opens a persistent gateway connection and receives events; outbound actions use the REST HTTP API." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.inbound_auth] +scheme = "none" +note = "Gateway handshake via Identify (opcode 2) carrying the bot token + intents; REST calls use `Authorization: Bot `. Gateway is a bot-initiated persistent socket, so there is no per-event inbound signature to verify (unlike webhook platforms)." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.threads] +model = "native" +note = "ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12) — temporary sub-channels of a text/forum/announcement channel (API v9+). Threads are themselves channels (own channel_id, thread_metadata, parent_id)." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.slash_commands] +supported = true +note = "Application commands of type CHAT_INPUT (1), registered over HTTP: global POST /applications/{id}/commands or per-guild. Invocations delivered as INTERACTION_CREATE with an interaction token for the response." +source = "https://docs.discord.com/developers/interactions/application-commands" + +[capability.mentions] +method = "at_mention" +note = "Bot detects being addressed via user mention <@user_id> (and legacy nick form <@!id>) or role mention <@&role_id> in mention_roles. Gateway also flags mentions / mention_everyone." +source = "https://docs.discord.com/developers/resources/message" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Add: PUT Create Reaction. Remove: Delete Own Reaction (own) or delete others' with MANAGE_MESSAGES. Receives MESSAGE_REACTION_ADD / MESSAGE_REACTION_REMOVE (needs GUILD_MESSAGE_REACTIONS intent 1<<10; DIRECT_MESSAGE_REACTIONS for DMs)." +source = "https://docs.discord.com/developers/resources/message" + +[capability.edit_message] +supported = true +note = "A bot may edit its own messages via Edit Message." +source = "https://docs.discord.com/developers/resources/message" + +[capability.delete_message] +supported = true +scope = "own_and_others" +note = "Own: always. Others': requires MANAGE_MESSAGES permission." +source = "https://docs.discord.com/developers/resources/message" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Markdown, embeds, and message components (buttons, string/select menus, action rows)." +source = "https://docs.discord.com/developers/resources/message" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 10 +note = "Inbound & outbound arbitrary file types. Default upload cap 10 MiB per file (raised by uploader Nitro status or server Boost tier)." +source = "https://docs.discord.com/developers/reference" + +[capability.message_length_limit] +max_chars = 2000 +note = "2000 characters per message content." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.dm_support] +supported = true +note = "1:1 DM channels (private channels)." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.group_model] +kinds = ["guild", "channel", "thread", "dm", "group_dm"] +note = "Guild → channels (GUILD_TEXT, forum, announcement, voice) → threads. Plus DM / group-DM private channels. Threads are channels with a parent_id." +source = "https://docs.discord.com/developers/resources/channel" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable per-user snowflake author.id on every message; not consent-gated. Requires the MESSAGE_CONTENT intent to also read message text." +source = "https://docs.discord.com/developers/resources/message" + +[capability.send_model] +model = "push_only" +note = "No reply-window/TTL — a bot with channel access may send at any time via REST. Replies are opt-in via message_reference{message_id}." +source = "https://docs.discord.com/developers/resources/message" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Unsolicited sends allowed within permissions. Global cap 50 requests/sec/bot; per-route buckets (X-RateLimit-Bucket); invalid-request cap 10,000/10min." +source = "https://docs.discord.com/developers/topics/rate-limits" + +[capability.bot_to_bot] +delivered = true +note = "The gateway delivers other bots' messages; the author.bot flag distinguishes them (the bot's own messages arrive too and must be self-filtered)." +source = "https://docs.discord.com/developers/topics/gateway" + +[capability.typing_indicator] +supported = true +note = "POST /channels/{id}/typing (Trigger Typing); inbound TYPING_START event." +source = "https://docs.discord.com/developers/topics/gateway" + + +# ═══ Schema 2 — openab-feature-support (source: our code + PR) ═══════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "ChannelId::say; resolve_channel prefers thread_id over channel_id; message_limit() = 2000." +source = ["crates/openab-core/src/discord.rs#resolve_channel", "crates/openab-core/src/discord.rs#message_limit"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Router reads message_limit() (2000) then splits: split_delivery handles directive/body, format::split_message chunks the body, mentions are propagated to each chunk." +source = ["crates/openab-core/src/adapter.rs#split_delivery", "crates/openab-core/src/format.rs#split_message"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "implemented" +note = "Post-then-edit, not native. use_streaming returns true only when no other bot is present (!other_bot_present); the router consults it at dispatch. uses_native_streaming stays default false, so the trait's native stream methods only hit their edit-based fallbacks." +source = ["crates/openab-core/src/discord.rs#use_streaming", "crates/openab-core/src/adapter.rs#uses_native_streaming"] +pr = "#534" + +[[openab_features]] +feature = "reply_quote" +status = "implemented" +note = "send_message_with_reply sets reference_message; falls back to plain send on invalid id (parses to 0) or reply failure (unknown/cross-channel message)." +source = ["crates/openab-core/src/discord.rs#send_message_with_reply"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "Native EditMessage.content (overrides the trait default that returns \"edit_message not supported\")." +source = ["crates/openab-core/src/discord.rs#EditMessage"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "implemented" +note = "Native http.delete_message (overrides trait default which edits to a zero-width space \\u{200b})." +source = ["crates/openab-core/src/discord.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "implemented" +note = "add_reaction = create_reaction, remove_reaction = delete_reaction_me. Unicode emoji only." +source = ["crates/openab-core/src/discord.rs#add_reaction", "crates/openab-core/src/discord.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "create_thread builds a thread from the trigger message via serenity create_thread_from_message (1-day auto-archive); auto-thread on first channel message via get_or_create_thread. Not the gateway create_topic path — the native adapter creates threads directly." +source = ["crates/openab-core/src/discord.rs#create_thread", "crates/openab-core/src/discord.rs#get_or_create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Attachments processed inline in the per-attachment loop: images encoded (download_and_encode_image), text files (≤1 MB total, ≤5 files), video passed as a URL block; non-image files warned to the user." +source = ["crates/openab-core/src/discord.rs#download_and_encode_image"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "implemented" +note = "Audio attachments transcribed via media::download_and_transcribe when stt_config.enabled; transcript injected + echoed; 🎤 reaction when STT disabled." +source = ["crates/openab-core/src/discord.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: adapter-level channel/user allowlist (allowed_channels, is_denied_user) + shared L3 identity gate router.gate_incoming (humans only; bots bypass via l3_gate_applies)." +source = ["crates/openab-core/src/discord.rs#is_denied_user", "crates/openab-core/src/discord.rs#l3_gate_applies", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "partial" +note = "On a denied user the bot reacts 🚫 on the offending message and drops it — no text reply (Discord L3 denies drop silently apart from the reaction)." +source = ["crates/openab-core/src/discord.rs#is_denied_user"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "AllowUsers modes: Mentions (always require @), Involved (skip @ if bot owns/participated in thread), MultibotMentions (require @ when other bots present). DMs treated as an implicit mention." +source = ["crates/openab-core/src/discord.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "Global commands registered on ready via set_global_commands: /models, /agents, /cancel, /cancel-all, /reset, /remind, /auth, /export-thread; dispatched via interaction_create." +source = ["crates/openab-core/src/discord.rs#set_global_commands", "crates/openab-core/src/discord.rs#interaction_create"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; trusted_bot_ids + @mention admits handoff regardless of allow_bot_messages." +source = ["crates/openab-core/src/discord.rs#MultibotCache", "crates/openab-core/src/discord.rs#BotTurnTracker"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Per-thread dispatch keyed by dispatcher.key(\"discord\", channel_id, sender_id); thread↔parent allowlist via detect_thread; ambient mode buffers passive-channel messages." +source = ["crates/openab-core/src/discord.rs#detect_thread"] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Threads are channels" +note = """ +A Discord thread has its own channel_id; the adapter resolves outbound targets via thread_id.unwrap_or(channel_id) (resolve_channel). Thread identity is thread_metadata.is_some() — parent_id alone is NOT reliable (category children also carry parent_id), so detect_thread returns early unless has_thread_metadata, and only uses parent_id for the allowlist check. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#detect_thread" + +[[quirks]] +date = "2026-07-04" +title = "Self-echo and bot-loop control" +note = """ +The bot receives its own messages over the gateway and must self-filter (msg.author.id == bot_id). Because multiple bots can ping-pong, there are layered guards: a hard consecutive-bot cap (MAX_CONSECUTIVE_BOT_TURNS = 1000), a configurable soft per-thread max_bot_turns reset by any human message, and BotTurnTracker. Bot-turn counting deliberately runs before the self-check so all bot messages count, but warning posts respect the channel allowlist + prior participation to avoid uninvolved bots spamming. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#BotTurnTracker" + +[[quirks]] +date = "2026-07-04" +title = "Multibot detection is irreversible & disk-cached" +note = """ +Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to MultibotCache on disk (survives restarts), since bot messages don't disappear. This flips streaming off (the edit-loop interferes across bots) and can require @mention under MultibotMentions. +""" +kind = "intrinsic" +source = "crates/openab-core/src/discord.rs#MultibotCache" + +[[quirks]] +date = "2026-07-04" +title = "Streaming is post-then-edit, not native" +note = """ +Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. use_streaming disables this whenever another bot is present, to avoid edit interference. uses_native_streaming stays false, so the trait's native stream methods only ever hit their edit-based fallbacks. +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#use_streaming" +refs = ["#534"] + +[[quirks]] +date = "2026-07-04" +title = "create_topic vs create_thread" +note = """ +The shared gateway layer has a create_topic command (gateway.rs) for gateway-protocol adapters. The native Discord adapter does NOT use it — it calls serenity create_thread_from_message directly (create_thread) and auto-creates a thread for top-level channel messages via get_or_create_thread. +""" +kind = "openab_decision" +source = "crates/openab-core/src/gateway.rs" + +[[quirks]] +date = "2026-07-04" +title = "Attachment handling caps" +note = """ +Text-file attachments are bounded independently of Discord's own 10 MiB upload cap: 1 MB total across all text files (TEXT_TOTAL_CAP) and max 5 files per message (TEXT_FILE_COUNT_CAP), enforced with a Discord-reported-size pre-check before download. Image URLs from Discord expire ~24h, which is surfaced to the agent in the injected block. +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs" + +[[quirks]] +date = "2026-07-04" +title = "DMs cannot hold threads" +note = """ +DM channels can't hold threads, so DMs reuse the DM channel directly and are treated as an implicit @mention; gated only by allow_dm + user allowlist (should_process_dm / should_skip_thread_creation). +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#should_process_dm" + +[[quirks]] +date = "2026-07-04" +title = "Finding: default file-upload cap is 10 MiB/file" +note = """ +Default file-upload cap is 10 MiB/file (raised by Nitro/Boost); the adapter's own text-attachment caps (1 MB total / 5 files) are stricter. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/reference" + +[[quirks]] +date = "2026-07-04" +title = "Finding: global REST rate limit is 50 req/sec/bot" +note = """ +Global REST rate limit is 50 req/sec/bot plus per-route buckets (X-RateLimit-Bucket); invalid requests capped at 10,000/10min — relevant to proactive-push and streaming edit-loop cadence. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/topics/rate-limits" + +[[quirks]] +date = "2026-07-04" +title = "Finding: message content hard limit is 2000 chars" +note = """ +Message content hard limit is 2000 chars, matching DiscordAdapter::message_limit(); the router chunks longer replies. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/resources/channel" + +[[quirks]] +date = "2026-07-04" +title = "Finding: thread channel types and identification" +note = """ +Thread channel types are ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12), API v9+; identified by thread_metadata, not parent_id (category children also carry parent_id); detect_thread follows this. +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/resources/channel" + +[[quirks]] +date = "2026-07-04" +title = "Finding: transport is a bot-initiated persistent WebSocket Gateway" +note = """ +Transport is a bot-initiated persistent WebSocket Gateway (WSS) authenticated by Identify(op 2) + bot token + intents — no per-event inbound signature to verify (unlike webhook platforms). +""" +kind = "intrinsic" +source = "https://docs.discord.com/developers/topics/gateway" + +[[quirks]] +date = "2026-07-04" +title = "Finding: Section-2 ref audit (stale line refs corrected)" +note = """ +Section-2 ref audit: chunking lives in adapter.rs (split_delivery / format::split_message), not the trait def; slash-command registration is set_global_commands; is_denied_user and trusted-bot bypass live in discord.rs. Corrected stale line refs. PR # not yet assigned (was PR #TBD in the source md). +""" +kind = "openab_decision" +source = "crates/openab-core/src/discord.rs#is_denied_user" diff --git a/docs/platforms/schema/feishu.toml b/docs/platforms/schema/feishu.toml new file mode 100644 index 000000000..fbcd1d16a --- /dev/null +++ b/docs/platforms/schema/feishu.toml @@ -0,0 +1,372 @@ +# Feishu / Lark — platform capability schema +# Generated from docs/platforms/feishu.md + +schema_version = "2026-07-07" + +[platform] +name = "feishu" +official_docs = "https://open.feishu.cn/document/home/index" +description = "Feishu / Lark enterprise IM bot platform (feishu.cn / larksuite.com); adapter lives in the openab-gateway crate." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "websocket" +note = "Both: WebSocket long-connection (default; protobuf pbbp2.Frame, ACK per event) and webhook (FEISHU_CONNECTION_MODE=webhook). Ingested event: im.message.receive_v1." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.inbound_auth] +scheme = "aes" +note = "Webhook L1 = SHA256(timestamp + nonce + encrypt_key + body) header signature (verify_signature) + optional constant-time verification_token; event body AES-256-CBC with key = SHA256(encrypt_key), IV = first 16 bytes of ciphertext (decrypt_event). WS: auth via AppID+AppSecret handshake to fetch endpoint." +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case" + +[capability.threads] +model = "native" +note = "Messages carry root_id / parent_id; reply via POST /im/v1/messages/{id}/reply with optional reply_in_thread=true (default false). If parent is already a thread, replies stay in-thread automatically. parent_id for a thread always points at the thread root." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/reply" + +[capability.slash_commands] +supported = false +note = "No native slash commands. Bots have a custom menu (application.bot.menu_v6 / menu-click event; <=3 main + <=5 sub, DM-only, no group support). Command-style input is just plain text." +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu" + +[capability.mentions] +method = "at_mention" +note = "@mention via event.message.mentions[] (each has a key placeholder like @_user_1 in text + id.open_id). Bot detects itself by matching its own open_id (resolved via /bot/v3/info)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Bot can add (POST /im/v1/messages/{id}/reactions) and remove (list -> find own reaction_id -> DELETE). Bot can receive reaction events via im.message.reaction.created_v1 / im.message.reaction.deleted_v1 (subscription required)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create" + +[capability.edit_message] +supported = true +note = "Own message only (calling identity must equal sender, else errcode 230071). PATCH /im/v1/messages/{id} updates text / post. Officially documented hard cap of 20 edits per message (errcode 230072 when exceeded); edit-time window is set by enterprise admin (errcode 230075). Card (interactive) updates use a separate endpoint, not subject to the 20-edit cap. Global API frequency limit 1000/min / 50 QPS (errcode 230020)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[capability.delete_message] +supported = true +scope = "own" +note = "Own message, DELETE /im/v1/messages/{id} (recall). Not subject to the edit cap. Deleting others' messages is admin/permission-gated, not a bot capability." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/delete" + +[capability.rich_content] +markdown = false +cards = true +buttons = true +note = "post (rich text: text/link/at/img/code_block), interactive CardKit v2 cards (buttons, markdown, tables, streaming). Plain markdown is NOT natively rendered as a text message — must be converted to post or a card." +source = "https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "file"] +max_size_mb = 100 +note = "Inbound: image / file / audio / media / post-embedded images; downloaded via /im/v1/messages/{id}/resources/{key}. Outbound: images/files uploaded first, then referenced by key. ? Feishu file-size limits are large (image ~10 MB, file ~30-100 MB tier-dependent) — exact tiers unverified." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/get-2" + +[capability.message_length_limit] +max_chars = 150000 +note = "text <= 150 KB; post / card <= 30 KB (per content JSON; card templates / style tags can inflate rendered size beyond the request body). Cap here counts bytes of the text content JSON, not chars." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.dm_support] +supported = true +note = "Yes — chat_type = \"p2p\"." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.group_model] +kinds = ["group", "user"] +note = "chat (group). Event chat_type: p2p (DM) vs group; each has a stable chat_id (oc_...). No separate channel/thread taxonomy — threads are message-level (root_id)." +source = "https://open.feishu.cn/document/server-docs/group/chat/chat-overview" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable & always present. sender.sender_id.open_id (ou_...) is a per-app stable user id delivered in every group event; not consent-gated (display-name lookup via Contact API may need scope)." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive" + +[capability.send_model] +model = "push_only" +note = "Push model — POST /im/v1/messages (or /reply) with a tenant_access_token (TTL ~7200 s, auto-refreshed). No per-message reply-window / token TTL like LINE; any message can be sent as long as the bot is in scope / in the group. The reply API's uuid is only a 1-hour idempotency window, not a reply deadline." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Yes, to users within the bot's availability scope and groups the bot has joined (with speaking permission). Rate limits: 5 QPS per same-user, 5 QPS shared per same-group, global 1000/min / 50 QPS. User opt-out -> errcode 230053; user outside availability scope / disabled -> errcode 230013. No hard daily quota." +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[capability.bot_to_bot] +delivered = false +note = "Effectively no. Feishu does NOT push another bot's messages to a bot's WebSocket, and marks other bots' messages as sender_type=\"user\", so a peer bot is indistinguishable without an explicit id allowlist. (Platform docs are silent on bot->bot event delivery — behavior verified only via the adapter, not an official statement.)" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + +[capability.typing_indicator] +supported = false +note = "Not exposed to bots — no public typing/inputting API is documented (Message FAQ is silent on it). OpenAB signals progress with emoji reactions / a streaming card instead. (Absence confirmed by omission; cannot be stated as an affirmative platform guarantee.)" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "Replies sent as post (rich text) so markdown renders; send_post_message. send_text_message kept only for webhook fallback/tests (#[allow(dead_code)])." +source = ["crates/openab-gateway/src/adapters/feishu.rs#send_post_message", "crates/openab-gateway/src/adapters/feishu.rs#send_text_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Long replies chunked at message_limit (default 4000, FEISHU_MESSAGE_LIMIT) via split_text (UTF-8-safe, breaks on newline/space); partial-chunk failure is reported as failure to core." +source = ["crates/openab-gateway/src/adapters/feishu.rs#split_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "workaround" +note = "No native content-streaming API. Emulated by PATCH-editing a post in place, with an auto post->card promotion: StreamingMode::Auto (default) promotes to a CardKit v2 streaming card when text is large (card_promote_bytes, default 4000), or contains a code fence / markdown table (should_use_card), or when the 20-edit post cap is hit (in handle_card_edit). post mode = kill-switch. Idle reaper finalizes cards." +source = ["crates/openab-gateway/src/adapters/feishu.rs#should_use_card", "crates/openab-gateway/src/adapters/feishu.rs#handle_card_edit", "crates/openab-gateway/src/adapters/feishu.rs#run_idle_reaper"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "implemented" +note = "send_message_with_reply -> gateway quote_message_id; adapter prefers quote_message_id over thread_id, uses /messages/{id}/reply, and falls back to a plain send if reply-to fails." +source = ["crates/openab-core/src/gateway.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/feishu.rs"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "Real in-place PATCH (edit_feishu_message). Core overrides the trait default (which returns \"unsupported\") because Feishu acks writes: edit_message uses an 800 ms request/response so it can see cap signals. Preemptive local cap at 18 edits (FEISHU_EDIT_CAP, 2-edit safety margin) + server errcode 230072 detection." +source = ["crates/openab-gateway/src/adapters/feishu.rs#edit_feishu_message", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "implemented" +note = "Native DELETE /im/v1/messages/{id} (delete_feishu_message); core overrides the trait default (edit-to-zero-width) because Feishu has a real delete. Used to remove the half-edited placeholder during cap-recovery / card-promotion." +source = ["crates/openab-gateway/src/adapters/feishu.rs#delete_feishu_message", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "partial" +note = "add_reaction implemented; remove_reaction implemented via list->match-own-reaction_id->DELETE. Limited to an 8-emoji hard-coded map (eyes/thinking/fire/technologist/zap/ok/thumbsup/scream); anything else is silently dropped. Inbound reaction events are NOT ingested (adapter only parses im.message.receive_v1)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#emoji_to_feishu_reaction", "crates/openab-gateway/src/adapters/feishu.rs#add_reaction", "crates/openab-gateway/src/adapters/feishu.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "partial" +note = "Native thread replies work (via root_id / reply). But the gateway create_topic command (from core create_thread) is a no-op — the Feishu adapter explicitly skips it and core falls back to replying in the same channel." +source = ["crates/openab-gateway/src/adapters/feishu.rs", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Images (resized to <=1200 px, JPEG q75, <=10 MB; GIF passthrough), text files (extension-allowlisted, <=512 KB), audio (<=25 MB, Whisper cap), and post-embedded images. Oversized/unsupported -> Attachment::rejected." +source = ["crates/openab-gateway/src/adapters/feishu.rs#download_feishu_image", "crates/openab-gateway/src/adapters/feishu.rs#download_feishu_file", "crates/openab-gateway/src/adapters/feishu.rs#download_feishu_audio"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "Adapter only downloads audio into an audio attachment (<=25 MB); actual speech-to-text is done downstream (Whisper) by core, not in the adapter." +source = ["crates/openab-gateway/src/adapters/feishu.rs#download_feishu_audio"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: adapter-side allowed_users / allowed_groups allowlists + bot/self filtering in parse_message_event; core-side shared ingress gate_incoming (L2 scope + L3 identity) before dispatch." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "On core L3 DenyIdentity, gateway echoes a throttled deny message (\"You are not on this bot's trusted list. Your ID: ... Ask the admin to add it to allowed_users.\"), <=1 echo per platform:sender per window. DenyScope -> silent drop." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Groups require @mention unless bot has participated in the thread (Discord-style \"involved\"). Modes via FEISHU_ALLOW_USER_MESSAGES: involved / mentions / multibot_mentions (default — require @mention if another bot is in the thread). Participation cache TTL = FEISHU_SESSION_TTL_HOURS (24 h)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-gateway/src/adapters/feishu.rs#detect_and_mark_multibot"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "n_a" +note = "Platform has no slash-command system; /reset and /cancel arrive as plain text and are intercepted by core (not the adapter). Adapter does no slash parsing." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "partial" +note = "FEISHU_ALLOW_BOTS (off/mentions/all) + FEISHU_TRUSTED_BOT_IDS + per-chat max_bot_turns (default 20) loop-guard. Caveat: Feishu marks other bots as sender_type=\"user\" and doesn't push bot messages over WS, so without trusted_bot_ids peer bots can't be identified; multibot_mentions detection is done via @mention of a known bot id, not sender type." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event", "crates/openab-gateway/src/adapters/feishu.rs#detect_and_mark_multibot"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Routing keyed by chat_id; channel_type = direct (p2p) vs group; thread_id from root_id / parent_id. Dedupe by event_id + message_id; self-echo dedupe on sent message_id (Feishu pushes the bot's own messages back)." +source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event"] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "20-edit-per-message cap -> streaming card promotion" +note = """ +Feishu officially documents a hard limit of 20 edits per message: PATCH /im/v1/messages/{id} for text/post returns errcode 230072 once exceeded ("一条消息最多可编辑 20 次" / "the message has reached the number of times it can be edited"). This is the central constraint shaping OpenAB streaming. The adapter (a) preemptively stops at 18 edits (FEISHU_EDIT_CAP, 2-edit safety margin for in-flight races), (b) parses the body code (JSON-code-first, substring fallback) to catch server-side 230072 even on HTTP 200, and (c) on cap, promotes to a CardKit v2 streaming card (no such cap) or, in post mode, lets core's finalize path delete the placeholder and send fresh content. Card-update uses its own 5-QPS/message frequency limit (errcode 230020) instead. (The edit-time window is separately admin-configured, errcode 230075.) +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[[quirks]] +date = "2026-07-04" +title = "post-vs-card streaming state machine" +note = """ +FEISHU_CARD_STREAMING_MODE = auto (default) | card | post. In auto, short replies stay a native post reply (nicer thread UI); long / code-fence / table replies (which markdown_to_post degrades — tables are dropped entirely, Issue #1124) promote to a card (should_use_card, with has_code_fence / has_markdown_table). The post->card swap is invisible to core: the gateway keeps reporting the original om_post message_id back so core's edit loop is oblivious. FEISHU_CARD_FALLBACK_TO_POST=true (default) is a second safety net back to the post path on any card failure. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#should_use_card" + +[[quirks]] +date = "2026-07-04" +title = "markdown rendering is lossy" +note = """ +markdown_to_post converts to Feishu post: code fences -> code_block, [t](url) -> a, and bold/italic/strike markers are stripped (not rendered). Markdown tables are not representable in post at all — that's a primary reason the auto-promotion to CardKit exists (cards render tables/markdown natively). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#markdown_to_post" + +[[quirks]] +date = "2026-07-04" +title = "message_id shape validation (defence-in-depth)" +note = """ +Any command that interpolates a message_id into a REST path (edit_message, delete_message, add_reaction, remove_reaction) is gated by is_valid_feishu_message_id (om_ + [A-Za-z0-9_], len 4-128), rejecting crafted ids containing / ? #. Internal card message_ids from trusted gateway session state bypass the check. The "draft" sentinel is a benign no-op skip. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#is_valid_feishu_message_id" + +[[quirks]] +date = "2026-07-04" +title = "bot identity & self/peer-bot handling" +note = """ +Bot open_id is resolved at startup and on WS reconnect via /bot/v3/info; without it, mention gating can't work. Feishu pushes the bot's own sent messages back over WS (handled by self-echo dedupe on the returned message_id) and does NOT push other bots' messages to a bot — so multibot detection relies on @mentions of known bot ids, not on sender_type (which is user for peer bots). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#parse_message_event" + +[[quirks]] +date = "2026-07-04" +title = "webhook security posture" +note = """ +Webhook path enforces: 1 MB body cap, per-IP rate limit, SHA256 signature (only if FEISHU_ENCRYPT_KEY set — a warning is logged and verification is skipped if unset), AES-256-CBC decrypt of encrypt payloads (key = SHA256(encrypt_key), IV = first 16 bytes), constant-time verification_token check, and the URL-verification challenge handshake. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#verify_signature" + +[[quirks]] +date = "2026-07-04" +title = "20-edit hard cap officially documented (edit errcodes)" +note = """ +20-edit-per-message hard cap is officially documented (errcode 230072); edit is sender-only (230071); edit-time window is admin-configured (230075) — not a fixed 14-day/230031 limit as previously drafted. Global 1000/min / 50 QPS, per-message 5 QPS (230020). +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" + +[[quirks]] +date = "2026-07-04" +title = "message size & send rate limits" +note = """ +Text content <=150 KB; post/card <=30 KB; 5 QPS/user & 5 QPS/group shared, 1000/min / 50 QPS global; user opt-out -> 230053, out-of-scope -> 230013. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/create" + +[[quirks]] +date = "2026-07-04" +title = "native threads via reply API, no reply deadline" +note = """ +Reply API supports native threads via reply_in_thread (default false) + root_id/parent_id; no reply-time deadline (the uuid is a 1-hour idempotency window only). +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message/reply" + +[[quirks]] +date = "2026-07-04" +title = "inbound reaction events exist but not yet ingested" +note = """ +Bot can add reactions and remove by reaction_id; inbound reaction events exist as im.message.reaction.created_v1/deleted_v1 (subscription required) — OpenAB adapter does not yet ingest them. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create" + +[[quirks]] +date = "2026-07-04" +title = "no native slash commands, DM-only bot menu" +note = """ +No native slash commands; only a DM-only custom bot menu (<=3 main/<=5 sub). OpenAB treats /reset etc. as plain text, intercepted in core. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu" + +[[quirks]] +date = "2026-07-04" +title = "no typing-indicator or bot->bot delivery documented" +note = """ +No public bot typing-indicator API and no documented bot->bot event delivery — Message FAQ is silent on both; OpenAB uses reactions/streaming card for progress and infers peer bots via @mention only. +""" +kind = "intrinsic" +source = "https://open.feishu.cn/document/server-docs/im-v1/faq" + +[[quirks]] +date = "2026-07-04" +title = "adapter relies on 230072 cap -> CardKit streaming-card promotion" +note = """ +Adapter relies on the 230072 cap -> CardKit streaming-card promotion; card streaming (S6) defaults to auto. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#should_use_card" + +[[quirks]] +date = "2026-07-04" +title = "create_topic is a deliberate no-op" +note = """ +create_topic (from core create_thread) is a deliberate no-op in the Feishu adapter — core falls back to same-channel reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs" + +[[quirks]] +date = "2026-07-04" +title = "reaction emoji support limited to 8-entry map" +note = """ +Reaction emoji support limited to an 8-entry hard-coded map; unmapped emojis are silently dropped. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/feishu.rs#emoji_to_feishu_reaction" + +[[quirks]] +date = "2026-07-04" +title = "slash commands intercepted in core, not adapter" +note = """ +Slash commands /reset and /cancel are intercepted in core gateway.rs, not the adapter; dispatch.rs does not hold the literals. +""" +kind = "openab_decision" +source = "crates/openab-core/src/gateway.rs" diff --git a/docs/platforms/schema/googlechat.toml b/docs/platforms/schema/googlechat.toml new file mode 100644 index 000000000..0cf79df50 --- /dev/null +++ b/docs/platforms/schema/googlechat.toml @@ -0,0 +1,359 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — Google Chat +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-07" + +[platform] +name = "googlechat" +official_docs = "https://developers.google.com/workspace/chat" +description = "Google Chat (Workspace) app — webhook/Pub-Sub interactions with REST send-back, threaded/flat spaces." + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ + +[capability.transport] +kind = "webhook" +note = "HTTP endpoint URL or Pub/Sub; adapter accepts both envelope shapes. Google Chat calls the app on interaction events; app must respond within ~30 s." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.inbound_auth] +scheme = "jwt_rs256" +note = "Bearer ID-token (JWT, RS256) in Authorization header, signed by Google, verified against Google JWKS (https://www.googleapis.com/oauth2/v3/certs). iss=https://accounts.google.com; aud verified against the app's configured audience (project number for HTTP-endpoint apps, or app URL); email claim must equal chat@system.gserviceaccount.com." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.threads] +model = "native" +note = "Spaces are either threaded or flat (in-line); reply targets a thread.name/threadKey with messageReplyOption (adapter uses REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD). Thread field is ignored in un-threaded spaces." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.slash_commands] +supported = true +note = "Registered in Cloud console (APIs & Services → Google Chat API → Configuration → Add a command; /name, commandId 1-1000). Delivered as a MESSAGE event carrying message.slashCommand / message.annotation.slashCommand with the matching commandId." +source = "https://developers.google.com/workspace/chat/commands" + +[capability.mentions] +method = "at_mention" +note = "In spaces the app is only invoked when @mentioned (or via slash command / app-added); mention text is stripped into argumentText. In DMs every message reaches the app. Mentions encoded as ( for @all)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = true +note = "Reaction resource + create/delete/list methods exist, BUT reactions.create/reactions.delete require USER authentication (REST reference lists only chat.messages.reactions.create / chat.messages.reactions / chat.messages scopes; no app-auth alternative). A chat.bot app therefore cannot add or remove reactions. Apps can still receive REACTION_ADDED/REACTION_REMOVED interaction events." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" + +[capability.edit_message] +supported = true +note = "spaces.messages.patch with updateMask=text; with app auth an app can update only messages it created. Scope chat.bot." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch" + +[capability.delete_message] +supported = true +scope = "own" +note = "spaces.messages.delete; with app auth an app can delete only messages it created (not user/other-app messages). Scope chat.bot." +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/delete" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Native text formatting (*bold*, _italic_, ~strike~, `code`, ```block```, links, mentions, bullet/quote). Cards v2 + interactive widgets/buttons/dialogs (app auth)." +source = "https://developers.google.com/workspace/chat/format-messages" + +[capability.attachments] +inbound = ["image", "audio", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 200 +note = "Inbound: user-uploaded attachments downloaded via Media API (UPLOADED_CONTENT; Drive-sourced needs the Drive API, not handled). Upload limit 200 MB; some file types are blocked. Outbound app uploads also up to 200 MB (a message with an attachment can't also carry accessory widgets)." +source = "https://developers.google.com/workspace/chat/upload-media-attachments" + +[capability.message_length_limit] +max_chars = 32000 +note = "Max message payload (text + cards) is 32,000 bytes; larger must be split into multiple messages. Cap is in bytes." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.dm_support] +supported = true +note = "1:1 DM space between a user and the app (space type DM). App receives all DM messages (no mention required)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.group_model] +kinds = ["dm", "group_chat", "space"] +note = "\"Spaces\": DM (1:1), group chat (unnamed multi-person), and named spaces/rooms. Spaces are threaded or flat. Routing keyed on space.name (e.g. spaces/AAAA)." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable & non-consent-gated — every message event carries sender = user resource name users/{id} + displayName + type (HUMAN/BOT)." +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.send_model] +model = "push_only" +reply_token_ttl_sec = 0 +max_objects_per_send = 0 +note = "Push (REST spaces.messages.create) with app auth; no reply-token/TTL window. Interaction responses may also be returned synchronously in the webhook response body." +source = "https://developers.google.com/workspace/chat/create-messages" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "App can post proactively to spaces it belongs to. Quota: 3,000 message writes/min per project (create+patch+delete combined), and 1 write/sec per space (shared across all apps in the space; 10/sec only during data import). 429 Too many requests on overflow → truncated exponential backoff." +source = "https://developers.google.com/workspace/chat/limits" + +[capability.bot_to_bot] +delivered = false +note = "Not delivered — the MESSAGE interaction event is defined as \"A user messages a Chat app\"; other apps'/bots' messages are not surfaced to a Chat app. (No doc positively states delivery; treat as unsupported. Adapter also drops sender.type == BOT inbound.)" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + +[capability.typing_indicator] +supported = false +note = "Not supported — Google Chat exposes no typing/composing API for apps; none of the documented app-facing interaction events or send surfaces include a typing signal. (Verified negative against the interactions surface.)" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "POST {space}/messages with markdown→gchat conversion; returns message name." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Splits at GOOGLE_CHAT_MESSAGE_LIMIT = 4096 (conservative vs the 32 KB API cap) on newline/space boundaries, UTF-8-safe; each chunk a separate message; first message id / first error acked." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#split_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "partial" +note = "No native streaming API. Gateway adapter leaves uses_native_streaming=false, so core streams via the post-then-edit_message loop; each edit sends the full accumulated text as a patch call." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#edit_message", "crates/openab-core/src/adapter.rs#uses_native_streaming"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "No dedicated quote/reply-to. send_message_with_reply uses the trait default → plain send_message; thread continuity is via thread_id, not a per-message quote." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply", "crates/openab-gateway/src/adapters/googlechat.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "PATCH {message_name}?updateMask=text; core gates the streaming edit rate on the ack. Own messages only (platform rule)." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#edit_message", "crates/openab-core/src/gateway.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "GatewayAdapter overrides delete_message to emit a fire-and-forget command:\"delete_message\" instead of the trait default (edit-to-zero-width). The googlechat adapter does not match delete_message in handle_reply (only add_reaction/remove_reaction/create_topic/edit_message), so it falls through to the send path with empty text → hits the empty-message short-circuit and sends nothing. Net: delete is a no-op on Google Chat." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#delete_message", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "not_implemented" +note = "Core dispatches add_reaction/remove_reaction commands but the adapter early-returns on them (match … => return). Deliberate: Google Chat reactions require USER auth; the app (chat.bot) cannot react. Status/thinking reactions therefore never render on Google Chat." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#add_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "partial" +note = "Inbound thread.name is captured and used to keep replies in-thread (send sets thread.name + messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD). create_thread→create_topic command is early-returned by the adapter, so explicit topic creation is a no-op and core falls back to the same channel." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Async download via Media API after the 200 response: images (resize longest side ≤1200px, JPEG q75, ≤10 MB; GIF passthrough); text-like files (extension whitelist, ≤512 KB each, ≤5 files / ≤1 MB aggregate); audio (≤25 MB, stored raw). Drive-sourced & video skipped." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#parse_attachments", "crates/openab-gateway/src/adapters/googlechat.rs#download_googlechat_image"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "Adapter downloads audio and emits it as an audio attachment with real MIME; STT happens in core only if stt_config.enabled, else forwarded as a \"transcription failed\" note." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#download_googlechat_audio", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Two layers: (1) webhook JWT verify — RS256 via Google JWKS + email==chat@system.gserviceaccount.com; (2) shared ingress router.gate_incoming L2 scope + L3 identity in process_gateway_event." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#verify_email_claim", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "DenyIdentity → throttled request-access echo (\"Your ID: …\") via adapter.send_message; DenyScope → silent drop. Platform-agnostic; delivered through the normal Google Chat send path." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Platform-native. Google Chat itself only delivers space messages when the app is @mentioned. Core should_skip_event mention-gating only fires for channel_type == group/supergroup; Google Chat spaces surface as ROOM/SPACE/DM, so the core @mention string check never triggers — gating is enforced by the platform, not core." +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "Core-side. /reset, /cancel, /model(s)/agents intercepted in process_gateway_event before dispatch; responses sent via adapter.send_message. Not wired to Google Chat's native slash-command registration — plain-text commands." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "partial" +note = "Bot senders are dropped inbound (sender.user_type == BOT → skip); core's multibot/other-bot streaming suppression can't observe other bots since Chat doesn't deliver their messages. use_streaming ignores other_bot_present for gateway adapters." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event", "crates/openab-core/src/gateway.rs#use_streaming"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Session/route keyed on space.name + optional thread_id; ChannelInfo carries id=space, channel_type=space type, thread_id." +source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event"] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Auth asymmetry: two independent credential paths" +note = "Inbound webhooks are authenticated by verifying Google's ID-token (email==chat@system.gserviceaccount.com, RS256 via JWKS, iss=accounts.google.com, audience-checked). Outbound API calls use a *separate* service-account → OAuth2 JWT-bearer exchange (scope=https://www.googleapis.com/auth/chat.bot), cached with a 300 s refresh margin. Missing outbound creds degrade to a logged dry-run that still acks failure to core." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#build_jwt" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reactions are structurally impossible for the bot" +note = "The single biggest divergence from other adapters: Google Chat's reaction API is user-auth only (the reactions.create REST reference explicitly lists only user-auth scopes). A chat.bot app cannot add/remove reactions at all, so OpenAB's status-reaction UX (👀 queued / 🤔 thinking / tool emojis / mood face) is silently dropped. The adapter deliberately early-returns on add_reaction/remove_reaction/create_topic rather than issuing doomed API calls. Consider a message-edit-based status line if a progress indicator is needed here." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Markdown must be raw, once" +note = "markdown_to_gchat converts CommonMark → Chat syntax (**b**→*b*, *i*→_i_, ~~s~~→~s~, [t](u)→, headings→bold, code fences/inline code passthrough). It is applied by *both* send_message and edit_message; passing already-converted text double-converts (e.g. *bold* re-parsed as *italic*). Core must always emit raw markdown on streaming edits." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#markdown_to_gchat" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "30-second webhook deadline vs. attachment downloads" +note = "Attachment-bearing messages spawn a background (panic-guarded) task and return {} immediately so the webhook meets Chat's ~30 s deadline; the GatewayEvent is emitted only after downloads finish. Text-only messages emit synchronously. Event dropped if both text and all attachments are empty/failed." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#webhook" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Two webhook envelope shapes" +note = "Google Chat delivers either top-level (message/user/space) for HTTP-endpoint mode or wrapped under chat.messagePayload for Pub/Sub mode. The handler prefers the wrapped form and falls back to top-level." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#webhook" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Chunk cap set below the API cap" +note = "Adapter chunks at 4096 chars while the API accepts 32,000 bytes — a conservative choice (matches the legacy client-visible limit) that produces more messages than strictly necessary for very long replies." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#GOOGLE_CHAT_MESSAGE_LIMIT" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Delete is a silent no-op (not even the edit fallback)" +note = "Unlike platforms where delete_message falls back to the trait's edit-to-zero-width, on Google Chat the delete_message command isn't matched in handle_reply, falls through to the send path with empty text, and hits the empty-message short-circuit — so nothing is sent and no edit occurs. Streaming-placeholder cleanup that relies on delete is therefore a no-op here." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#handle_reply" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reaction API requires user auth (findings log)" +note = "reactions.create/reactions.delete require user auth (REST ref lists only chat.messages.reactions* / chat.messages, no app-auth); a chat.bot app cannot add/remove reactions → OpenAB status-reaction UX is a no-op on this platform." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Max message payload is 32,000 bytes" +note = "Max message payload (text+cards) is 32,000 bytes; longer content must be split into multiple messages (adapter chunks conservatively at 4096 chars)." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/create-messages" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Edit/delete operate on own messages only" +note = "With app auth, messages.patch (edit) and messages.delete operate on only the app's own messages; edit updateMask supports text." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Attachment upload limit is 200 MB" +note = "Attachment upload limit is 200 MB; some file types are blocked (an attachment message can't also carry accessory widgets). OpenAB downloads far below this (image 10 MB / file 512 KB / audio 25 MB) and skips Drive-sourced & video attachments." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/upload-media-attachments" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Rate limits: 3,000/min project, 1/sec space" +note = "Rate limits: 3,000 message writes/min per project and 1 write/sec per space (shared across apps; 10/sec only during data import); 429 → truncated exponential backoff." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/limits" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Slash commands registered with commandId 1-1000" +note = "Slash commands are registered in the Cloud console with a commandId 1-1000 and delivered as a MESSAGE event carrying message.slashCommand / message.annotation.slashCommand." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/commands" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Mention-gating is enforced platform-side" +note = "In spaces the app is only invoked when @mentioned (mention stripped into argumentText); DMs deliver every message — so mention-gating is enforced platform-side, not by OpenAB's @mention string check." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "No bot-to-bot delivery and no typing API" +note = "The MESSAGE interaction event is defined as \"A user messages a Chat app\"; other bots'/apps' messages are not delivered, and there is no typing/composing API for apps." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Inbound webhooks signed by chat@system.gserviceaccount.com" +note = "Inbound webhooks are signed by chat@system.gserviceaccount.com (RS256, JWKS at oauth2/v3/certs); OpenAB verifies issuer (accounts.google.com), audience, exp, and the email claim." +kind = "intrinsic" +source = "https://developers.google.com/workspace/chat/receive-respond-interactions" +refs = [] diff --git a/docs/platforms/schema/line.toml b/docs/platforms/schema/line.toml new file mode 100644 index 000000000..be9ab8038 --- /dev/null +++ b/docs/platforms/schema/line.toml @@ -0,0 +1,383 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# LINE — machine-readable platform schema +# Converted from docs/platforms/line.md. Structure follows _template.toml. +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-07" + +[platform] +name = "line" +official_docs = "https://developers.line.biz/en/docs/messaging-api/" +description = "LINE Messaging API — bots run as LINE Official Accounts, receiving user events via webhook and sending via Reply/Push APIs." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "webhook" +note = "HTTPS POST to the bot's registered endpoint; events arrive as a batched events[] payload." +source = "https://developers.line.biz/en/docs/messaging-api/receiving-messages/" + +[capability.inbound_auth] +scheme = "hmac_sha256" +note = "HMAC-SHA256 over the raw request body, keyed by the channel secret, Base64-encoded, compared to the x-line-signature header." +source = "https://developers.line.biz/en/reference/messaging-api/#signature-validation" + +[capability.threads] +model = "none" +note = "No native threads/topics. LINE has flat 1:1 chats, group chats and multi-person rooms; there is no thread or topic primitive." +source = "https://developers.line.biz/en/reference/messaging-api/#source-user" + +[capability.slash_commands] +supported = false +note = "Not a platform feature — no command registration/delivery API. Any /cmd is just plain message text." +source = "https://developers.line.biz/en/reference/messaging-api/#message-event" + +[capability.mentions] +method = "self_flag" +note = "mention.mentionees[] on text message events; each mentionee carries an optional userId and an isSelf flag that is true for the bot itself (no username matching needed)." +source = "https://developers.line.biz/en/reference/messaging-api/#wh-text" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = false +note = "Bot cannot add/remove reactions (no API). Bot cannot receive them either: the documented webhook event list (message, unsend, follow/unfollow, join/leave, member join/leave, postback, video-viewing-complete, beacon, account-link, membership) contains no reaction event — verified against the current Messaging API reference, which does not surface a reaction webhook to bots." +source = "https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects" + +[capability.edit_message] +supported = false +note = "The API has no endpoint to edit an already-sent message." +source = "https://developers.line.biz/en/reference/messaging-api/" + +[capability.delete_message] +supported = false +scope = "none" +note = "No bot-initiated delete. Only the user can unsend, which delivers an unsend webhook to the bot; the bot cannot delete its own or others' messages." +source = "https://developers.line.biz/en/reference/messaging-api/#unsend-event" + +[capability.rich_content] +markdown = false +cards = true +buttons = true +note = "Rich messages: stickers, images, imagemap, buttons/confirm/carousel templates, and Flex Messages (JSON-defined layouts). No Markdown; text is plain (LINE emoji via product/emoji IDs)." +source = "https://developers.line.biz/en/docs/messaging-api/message-types/" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +note = "Inbound via get-content by message ID (/v2/bot/message/{id}/content on the api-data.line.me host): images, video, audio, files. contentProvider.type is \"line\" (fetchable) or \"external\" (URL only, not fetchable via get-content). User-sent content auto-expires, so fetch promptly. Outbound media is sent by URL, not upload." +source = "https://developers.line.biz/en/reference/messaging-api/#get-content" + +[capability.message_length_limit] +max_chars = 5000 +note = "5000 characters per text message object, counted in UTF-16 code units; chunking required above this." +source = "https://developers.line.biz/en/reference/messaging-api/#text-message" + +[capability.dm_support] +supported = true +note = "1:1 chat between a user and the LINE Official Account." +source = "https://developers.line.biz/en/reference/messaging-api/#source-user" + +[capability.group_model] +kinds = ["group", "room", "user"] +note = "Two multi-user taxonomies: group (groupId) and room / multi-person chat (roomId), plus 1:1 user chats. No channels/spaces." +source = "https://developers.line.biz/en/docs/messaging-api/group-chats/" + +[capability.group_sender_identity] +stable_id = "consent_gated" +note = "Consent-gated and unreliable: userId is optional in group/room source objects and is only present for users on LINE for iOS/Android; it can be absent otherwise." +source = "https://developers.line.biz/en/reference/messaging-api/#source-group" + +[capability.send_model] +model = "hybrid" +reply_token_ttl_sec = 60 +max_objects_per_send = 5 +note = "Reply API (/v2/bot/message/reply) consumes a one-time replyToken from the inbound webhook and is free; Push API (/v2/bot/message/push) targets a user/group/room ID at any time and counts against quota. Reply token must be used within ~1 minute; LINE explicitly says the limit may change without notice and use beyond one minute isn't guaranteed — don't rely on it. Both endpoints accept up to 5 message objects per request." +source = "https://developers.line.biz/en/reference/messaging-api/#send-reply-message" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Via Push API, but metered: each plan has a monthly free-message quota (amount depends on subscription plan/region), with paid overage. A get-quota/consumption API exists. Up to 5 message objects per request." +source = "https://developers.line.biz/en/docs/messaging-api/pricing/" + +[capability.bot_to_bot] +delivered = false +note = "LINE Official Accounts (bots) do not receive messages from other bots; webhook message events are for user-originated content." +source = "https://developers.line.biz/en/docs/messaging-api/overview/" + +[capability.typing_indicator] +supported = true +note = "\"Display a loading animation\" endpoint, 1:1 chats only (\"You can't specify group chats or multi-person chats\"), rate-limited to 100 req/s. It is a loading animation while the user is viewing the chat, not a per-keystroke typing indicator." +source = "https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "Outbound text via hybrid dispatch: tries Reply API first (free), falls back to Push API. Only {\"type\":\"text\"} objects are sent (no rich content)." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "message_split" +status = "partial" +note = "Router-level split_delivery + per-adapter message_limit handle length bounds generically; the LINE dispatcher itself sends a single text object per reply and does not re-chunk. LINE's own cap is 5000 chars/object." +source = ["crates/openab-core/src/adapter.rs#split_delivery", "crates/openab-core/src/adapter.rs#message_limit", "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "not_implemented" +note = "No native streaming; adapter does not override uses_native_streaming (trait default false) and LINE has no edit API to drive post+edit streaming. Effectively send-once/batched." +source = ["crates/openab-core/src/adapter.rs#uses_native_streaming", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "workaround" +note = "LINE \"reply\" is a delivery mechanism (reply-token), not a UI quote. dispatch_line_reply uses the token to answer in-context; there is no message-quote rendering. Reply vs Push chosen by token freshness." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "edit_message" +status = "n_a" +note = "LINE has no edit endpoint. Trait default edit_message returns \"edit_message not supported\"; adapter does not override." +source = ["crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "n_a" +note = "No LINE delete endpoint. Trait default delete_message falls back to editing to a zero-width space — which also fails on LINE since edit is unsupported." +source = ["crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "n_a" +note = "LINE exposes no add/remove-reaction API. The gateway dispatcher explicitly ignores add_reaction/remove_reaction commands (logs \"ignoring unsupported command\", returns false)." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "threads_topics" +status = "n_a" +note = "LINE has no thread primitive. The create_topic command is explicitly ignored by the dispatcher alongside the reaction commands." +source = ["crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply", "crates/openab-core/src/adapter.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "partial" +note = "Images and audio are downloaded via get-content (/v2/bot/message/{id}/content), size-guarded (Content-Length pre-check + streaming cap), and stored (images are resized/compressed; audio stored as-is). external-provider content and missing access-token produce a status-only attachment (not dropped); video/files are not ingested (event filter passes only text/image/audio)." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "not_implemented" +note = "Audio is downloaded and stored as an attachment only; no speech-to-text is performed in the LINE path." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "L1 signature at ingress (HMAC-SHA256 over raw body vs x-line-signature); shared L2 scope / L3 identity trust gate applies in the gateway ingress path, keyed by platform." +source = ["crates/openab-gateway/src/adapters/line.rs", "crates/openab-core/src/gateway.rs#process_gateway_event"] +pr = "#1291" + +[[openab_features]] +feature = "deny_echo" +status = "workaround" +note = "On L3 identity-deny the gateway echoes the sender their ID via adapter.send_message (throttled per platform:sender). On LINE that echo flows through the same hybrid dispatch_line_reply keyed by the original event's reply token, so it is Reply in practice (deny happens at ingress while the token is still fresh); note this is not a hard \"never Push\" guarantee — if the token were expired the shared dispatcher would still fall back to Push. 1:1 echo includes the UID; group/room echo carries no stable ID." +source = ["crates/openab-core/src/gateway.rs#process_gateway_event", "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply"] +pr = "#1291" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "In group/room events the adapter drops the message unless a mentionee has isSelf=true (the bot). 1:1 DMs always pass. No env var / bot-name matching needed — LINE flags self-mention." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + +[[openab_features]] +feature = "slash_commands" +status = "n_a" +note = "LINE has no slash-command surface; commands would arrive as plain text. /reset, /cancel handling is not wired in the LINE path (events are filtered to text/image/audio and forwarded as-is)." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "n_a" +note = "LINE does not deliver other bots' messages, and inbound is_bot is hard-coded false, so multi-bot coordination cannot trigger on LINE." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Channel keyed by groupId (group) / roomId (room) / userId (1:1); a group/room missing its ID is skipped. Sender falls back to \"unknown\" when userId is absent." +source = ["crates/openab-gateway/src/adapters/line.rs"] +pr = "#1291" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "Reply/Push hybrid dispatch (the core LINE model)" +note = """ +LINE splits outbound sending into two APIs with different economics. Every inbound webhook carries a one-time replyToken; using it (/message/reply) is free but the token is short-lived (~1 minute, officially "may change without notice" and not guaranteed beyond one minute). Push (/message/push) works anytime but consumes the monthly quota. OpenAB caches the token at webhook receipt time (TTL tracked from true receipt, REPLY_TOKEN_TTL_SECS = 50 in crates/openab-gateway/src/lib.rs, deliberately under LINE's ~60s), tries Reply first, and only falls back to Push when the token is missing/expired. The cache is bounded (REPLY_TOKEN_CACHE_MAX = 10_000) and swept periodically. + +Duplicate-safety bias: on a Reply API error that is not a clearly-unusable-token 400 (e.g. network error, or a non-token 4xx/5xx), the dispatcher does not fall back to Push — it assumes the reply may have landed and returns used_reply=true to avoid double-sending. Only an explicit "invalid … reply token" or "expired" 400 triggers Push fallback. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/lib.rs#REPLY_TOKEN_TTL_SECS" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "Sender identity is best-effort, \"unknown\" is never trusted" +note = """ +userId is optional in group/room sources (present only for LINE iOS/Android users). When absent, the sender collapses to the literal "unknown". Decision: "unknown" is never allowlistable — it cannot be added to allowed_users, because it is not a stable identity. Group admission for LINE is instead gated by self-mention. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "@mention gating in groups/rooms" +note = """ +In group/room events the adapter forwards the message only if some mentionee has isSelf=true — i.e. the bot itself was @-mentioned. 1:1 DMs always pass. This relies entirely on LINE's isSelf flag, so no bot-name string matching or env var is needed. A group/room text with no mention object, or one where only other users are mentioned, is dropped. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "Early-ack webhook processing" +note = """ +The webhook handler validates the signature, returns 200 OK, then spawns background processing so slow image/audio downloads don't cause LINE to redeliver. Tradeoff (documented in-code): once acked, a later crash is not retried by LINE, and cross-payload ordering can invert if an image event is slower than a following text event. A shared semaphore (line_webhook_semaphore, LINE_WEBHOOK_CONCURRENCY_MAX) bounds concurrent post-ack work to cap backlog under bursts; a saturated semaphore makes new webhooks wait before spawning. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "is_bot always false" +note = """ +Inbound events hard-code is_bot=false because LINE never delivers other bots' messages to a bot. This means the shared multibot machinery is inert on LINE by construction. +""" +kind = "intrinsic" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "External-content and unsupported media are surfaced, not silently dropped" +note = """ +When an image/audio uses contentProvider.type == "external", or the access token is unconfigured, the adapter emits an attachment with a status string (e.g. "unsupported format: external content not supported", "configuration error: service not configured") rather than dropping the event — so the agent still sees that media was present. Video and generic files are filtered out earlier (only text/image/audio pass the type filter). +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "No reaction webhook event; edit/delete endpoints absent" +note = """ +No reaction webhook event in the documented Messaging API event list — bots can neither add/remove nor receive reactions; edit/delete endpoints also absent. Confirms reactions/edit/delete are n/a in OpenAB. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Reply token ~1-minute window; 5 objects per request" +note = """ +Reply token must be used within ~1 minute; LINE warns the limit may change without notice and use beyond one minute isn't guaranteed — motivates the conservative 50s TTL. Both Reply and Push accept up to 5 message objects per request. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/sending-messages/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Text message objects cap at 5000 chars (UTF-16)" +note = """ +LINE text message objects cap at 5000 chars (counted in UTF-16 code units). +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#text-message" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Loading-animation (typing) indicator is 1:1-only" +note = """ +Loading-animation ("typing") indicator is 1:1-only ("You can't specify group chats or multi-person chats"), rate-limited 100 req/s. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "userId optional in group/room sources" +note = """ +userId is optional in group/room sources and only present for LINE iOS/Android users — confirms "unknown" sender is intrinsic, not a bug. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#source-group" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Inbound media fetched via get-content by message ID" +note = """ +Inbound media is fetched via get-content by message ID on the api-data.line.me host; contentProvider.type is line (fetchable) or external (URL only); user content auto-expires — fetch promptly. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/reference/messaging-api/#get-content" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Push metered by plan-dependent monthly free quota; Reply free" +note = """ +Push is metered by a plan-dependent monthly free-message quota; Reply API is free — the economic basis for the hybrid model. Open item (unknowable without running the platform): the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. +""" +kind = "intrinsic" +source = "https://developers.line.biz/en/docs/messaging-api/pricing/" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "deny-echo reuses hybrid dispatch (Reply in practice)" +note = """ +deny-echo on LINE reuses the hybrid dispatch_line_reply keyed by the original event token: Reply in practice (fresh token at ingress), but not a hard "never Push" guarantee — an expired token would fall back to Push like any reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs#dispatch_line_reply" +refs = ["#1291"] + +[[quirks]] +date = "2026-07-04" +title = "LINE adapter design summary" +note = """ +LINE adapter design: L1 HMAC-SHA256 at ingress, group two-mode admission via isSelf mention-gating, "unknown" never allowlistable, is_bot always false, early-ack + semaphore-bounded background processing, single text object per reply. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/line.rs" +refs = ["#1291"] diff --git a/docs/platforms/schema/slack.toml b/docs/platforms/schema/slack.toml new file mode 100644 index 000000000..9ea7ec5e5 --- /dev/null +++ b/docs/platforms/schema/slack.toml @@ -0,0 +1,328 @@ +# Slack — machine-readable platform schema +# Converted from docs/platforms/slack.md. See _template.toml for the schema. + +schema_version = "2026-07-07" + +[platform] +name = "slack" +official_docs = "https://docs.slack.dev/" +description = "Slack workspace messaging platform; OpenAB uses a native Socket Mode adapter." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "socket_mode" +note = "Persistent WebSocket obtained from apps.connections.open; no public URL." +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[capability.inbound_auth] +scheme = "none" +note = "None per-event. The WebSocket is pre-authenticated by the app-level token (xapp-) sent in the Authorization header to open it; inbound events need no HMAC/signature validation (explicitly unlike the HTTP Events API, where each event must be validated)." +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[capability.threads] +model = "native" +note = "A thread is implicit: any message posted with thread_ts = a parent message's ts becomes a reply in that thread. reply_broadcast=true optionally also surfaces the reply to the whole channel (default false = thread-only)." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.slash_commands] +supported = true +note = "Supported but HTTP-only — registered in app config with a Request URL; delivered via HTTP POST, not Socket Mode events. Developer slash commands cannot be invoked in message threads (only built-ins like /remind can)." +source = "https://docs.slack.dev/interactivity/implementing-slash-commands" + +[capability.mentions] +method = "at_mention" +note = "@bot renders in event text as <@BOT_UID> (or labelled <@BOT_UID|handle>); a dedicated app_mention event also fires. DMs are an implicit mention (no app_mention)." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Bot can add (reactions.add) and remove (reactions.remove) with reactions:write; receives reaction_added/reaction_removed events. Per-item caps on distinct emoji and per-person reactions." +source = "https://docs.slack.dev/reference/methods/reactions.add" + +[capability.edit_message] +supported = true +note = "chat.update, but only messages the bot itself authored (cant_update_message otherwise). Tier-3 rate limit; edit_window_closed if the workspace's message-edit settings forbid the edit." +source = "https://docs.slack.dev/reference/methods/chat.update" + +[capability.delete_message] +supported = true +scope = "own" +note = "chat.delete with chat:write — with a bot token, deletes only messages posted by that bot; cannot delete users'/other bots' messages (no impersonation accommodation)." +source = "https://docs.slack.dev/reference/methods/chat.delete" + +[capability.rich_content] +markdown = true +cards = true +buttons = true +note = "Block Kit (sections, buttons, cards, and a markdown block) + legacy mrkdwn. A Block Kit markdown block renders real headings/lists/tables/code fences." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 1024 +note = "Inbound: any file the bot can see, downloaded with the bot token (files:read). Outbound uploads capped at 1 GB per file (hard limit on all plans); free workspaces additionally cap total storage at 5 GB. Note: files.upload is deprecated — sunset 2025-11-12, replaced by files.getUploadURLExternal + files.completeUploadExternal." +source = "https://docs.slack.dev/changelog/2019-03-wild-west-for-files-no-more/" + +[capability.message_length_limit] +max_chars = 12000 +note = "text: recommended <= 4,000 chars, hard-truncated by Slack at 40,000. markdown_text (and the Block Kit markdown block the adapter uses): up to 12,000 chars." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.dm_support] +supported = true +note = "1:1 IM channels (channel IDs start with D)." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.group_model] +kinds = ["channel_public", "channel_private", "im", "mpim", "thread"] +note = "workspace → channels (public C… / private G…), plus IM (D…) and multi-party IM (mpim); threads inside channels." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable per-user user (U…) ID on every message event; resolvable to display/real name via users.info. Bot senders carry bot_id (B…) instead. Not consent-gated within an authorized workspace." +source = "https://docs.slack.dev/reference/events/message/" + +[capability.send_model] +model = "push_only" +note = "Push — no reply-window/token TTL. Any chat.postMessage targeting a channel the bot is in; threading is thread_ts, not a time-bounded reply token." +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Bot may post unsolicited to channels it's a member of. Rate: no more than 1 message/second/channel (short bursts tolerated). chat.postMessage is a Special Tier method carrying both this per-channel limit and a workspace-wide limit." +source = "https://docs.slack.dev/apis/web-api/rate-limits/" + +[capability.bot_to_bot] +delivered = true +note = "Other bots' messages are delivered as message events with subtype: bot_message and a bot_id/bot_profile; the receiving app must opt to process them." +source = "https://docs.slack.dev/reference/events/message/bot_message/" + +[capability.typing_indicator] +supported = false +note = "Not used as a typing dot. Assistant mode instead surfaces an ephemeral status line via assistant.threads.setStatus (Thinking…)." +source = "https://docs.slack.dev/reference/methods/assistant.threads.setStatus" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "chat.postMessage with Block Kit markdown blocks + text/mrkdwn fallback. Graceful degrade to text-only on invalid_blocks/msg_blocks_too_long." +source = ["crates/openab-core/src/slack.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "message_limit() = 11,900 (Block Kit markdown cap 12k minus ~100 headroom, MARKDOWN_BLOCK_LIMIT); router splits at that bound and each chunk is one markdown block via format::split_message. Known gap: split_message isn't table-aware, so a single table > 11,900 splits mid-table into raw pipes (continuation blocks lack the header/separator rows)." +source = ["crates/openab-core/src/slack.rs#message_limit", "crates/openab-core/src/slack.rs#MARKDOWN_BLOCK_LIMIT"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "implemented" +note = "Native streaming via chat.startStream/appendStream/stopStream when streaming && assistant_mode && !other_bot_present (uses_native_streaming); otherwise degrades to a post+edit placeholder; streaming=false = send-once. stopStream closes content-free (append semantics would duplicate — #1055), then chat.update writes the clean finalized Block Kit copy." +source = ["crates/openab-core/src/slack.rs#uses_native_streaming"] +pr = "#1055" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "No send_message_with_reply override → uses the trait default (adapter.rs) = plain send_message (reply_to ignored). Slack threading is instead expressed via thread_ts on the channel ref." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply", "crates/openab-core/src/slack.rs#send_message"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "chat.update with the same block payload + text-only degrade on invalid_blocks/msg_blocks_too_long. Also the substrate for degraded-stream mid-edits and stream finalization." +source = ["crates/openab-core/src/slack.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "workaround" +note = "No delete_message override → the trait default (adapter.rs) edits the message to a zero-width space (\\u{200b}) via edit_message rather than calling chat.delete. Effectively blanks, doesn't remove." +source = ["crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "implemented" +note = "reactions.add/reactions.remove; treats already_reacted/no_reaction as success (idempotent). Unicode→Slack shortname map is limited to a default set; unmapped emoji fall back to grey_question." +source = ["crates/openab-core/src/slack.rs#add_reaction", "crates/openab-core/src/slack.rs#remove_reaction"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "create_thread is a no-op mapping: returns a channel ref with thread_id = trigger ts (Slack threads are implicit, created by posting with thread_ts). No gateway create_topic (native adapter)." +source = ["crates/openab-core/src/slack.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "Images (download+encode), text files (5-file / 1 MB total cap, TEXT_FILE_COUNT_CAP/TEXT_TOTAL_CAP, mirroring Discord #291), audio → STT. Private files fetched with the bot token; failed images trigger a files:read/format hint message." +source = ["crates/openab-core/src/slack.rs#TEXT_FILE_COUNT_CAP", "crates/openab-core/src/slack.rs#TEXT_TOTAL_CAP"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "implemented" +note = "Audio attachments transcribed via media::download_and_transcribe when stt.enabled; transcript injected as a leading text block + best-effort echo (stt::post_echo). STT disabled → 🎤 reaction ack." +source = ["crates/openab-core/src/media.rs#download_and_transcribe", "crates/openab-core/src/stt.rs#post_echo"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "partial" +note = "Native adapter does NOT use the shared AdapterRouter::gate_incoming (L3 identity trust) — that's wired only for gateway and discord. Slack enforces inline allowlists: allowed_channels and allowed_users, plus trusted_bot_ids resolution (B…→U… via bots.info) for bot senders." +source = ["crates/openab-core/src/slack.rs#allowed_channels", "crates/openab-core/src/slack.rs#trusted_bot_ids_contains"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "partial" +note = "On a denied user, no text reply — reacts 🚫 to the offending message (add_reaction, inside the allowed_users deny branch). This is the native adapter's own deny UX, not the gateway's DenyIdentity ID-echo path." +source = ["crates/openab-core/src/slack.rs#add_reaction", "crates/openab-core/src/slack.rs#allowed_users"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "Per allow_user_messages: Mentions requires <@bot>; Involved requires bot participation in the thread; MultibotMentions additionally requires an @mention once another bot is in the thread. DMs are implicit mentions. app_mention handles the @-path (deduped against message)." +source = ["crates/openab-core/src/slack.rs#bot_participated_in_thread"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "not_implemented" +note = "slash_commands and interactive envelopes are ack'd and dropped: slash commands are blocked in thread composers and channel-level delivery lacks the thread_ts needed to route to a session. No in-text /reset//cancel parsing in the native path either (that lives in the gateway path)." +source = ["crates/openab-core/src/slack.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "Eager other-bot detection on inbound bot messages (note_other_bot_in_thread), persisted to a disk cache (irreversible). Disables streaming (use_streaming/uses_native_streaming gate on other_bot_present) and, with MultibotMentions, requires @mention. Consecutive-bot-turn cap (MAX_CONSECUTIVE_BOT_TURNS = 1000) + BotTurnTracker soft/hard limits guard loops." +source = ["crates/openab-core/src/slack.rs#note_other_bot_in_thread", "crates/openab-core/src/slack.rs#MAX_CONSECUTIVE_BOT_TURNS"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Routed through Dispatcher; key is grouping-dependent — slack: in Thread mode or slack:: in Lane mode (Dispatcher::key), with thread_id falling back to channel_id outside a thread. Sender context is serialized with the Slack-native thread_ts key so agents calling the API directly see the right field." +source = ["crates/openab-core/src/dispatch.rs#key", "crates/openab-core/src/slack.rs"] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "Socket Mode keepalive (deaf-socket guard)" +note = "Slack's inbound WebSocket can go half-open (NAT idle-timeout silently drops inbound frames with no Close/FIN), leaving read.next() blocked forever so the reconnect loop never fires — the bot appears connected but goes deaf. The adapter pings every 30s (PING_INTERVAL_SECS) and force-reconnects if no inbound frame — including Slack's own pings — arrives within 75s (IDLE_TIMEOUT_SECS). Backoff doubles to a 30s cap, mirroring the gateway: 1,2,4,8,16,30,30…" +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#PING_INTERVAL_SECS" + +[[quirks]] +date = "2026-07-04" +title = "Native streaming duplication trap (#1055)" +note = "chat.stopStream's markdown_text appends, it does not replace. Passing the full reply at finish would duplicate the entire message. The adapter closes the stream content-free, then chat.updates the finalized Block Kit copy. On the active path a failed final chat.update must NOT fall back to postMessage (would post a duplicate); on the degraded (post+edit) path it must, since no streamed content exists." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#uses_native_streaming" +refs = ["#1055"] + +[[quirks]] +date = "2026-07-04" +title = "Block Kit markdown vs legacy mrkdwn" +note = "Messages are sent as Block Kit markdown blocks (12k cap; real headings/tables/code fences), with a markdown_to_mrkdwn text fallback for notifications/a11y. message_limit is bumped 4000→11,900 to keep typical Markdown tables in one block; renders_native_tables()=true tells the router to skip the convert_tables pre-pass. Workspaces that reject the block (invalid_blocks/msg_blocks_too_long) get an automatic text-only retry (is_block_payload_rejected matches the trailing error code exactly, so invalid_blocks_field does not falsely trigger)." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#is_block_payload_rejected" + +[[quirks]] +date = "2026-07-04" +title = "app_mention vs message dedup" +note = "Both app_mention and message events can fire for one @mention. The adapter routes the @-path through app_mention and skips mention-bearing message events (except in DMs, where app_mention doesn't fire). Eager multibot detection and bot-turn tracking run BEFORE the self/bot gates (own-message skip) so own and filtered-out bot messages still count and are still detected (mirrors Discord #481/#483)." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#note_other_bot_in_thread" + +[[quirks]] +date = "2026-07-04" +title = "Positive-only, fail-closed caches" +note = "Participation and multibot are irreversible states, so their caches store positive results only; multibot is also disk-persisted (multibot_cache) to survive restarts. Thread-history checks (bot_participated_in_thread, and the AllowBots::All consecutive-bot loop cap) fail closed — an API error rejects the message rather than risk an unauthorized/looping response." +kind = "openab_decision" +source = "crates/openab-core/src/slack.rs#bot_participated_in_thread" + +[[quirks]] +date = "2026-07-04" +title = "Native trust divergence" +note = "Unlike the gateway/Discord paths (which call AdapterRouter::gate_incoming), the Slack adapter predates the unified trust gate and enforces access with inline allowed_channels/allowed_users/trusted_bot_ids checks. Deny UX is a 🚫 reaction, not the gateway's DenyIdentity identity-echo. A future consolidation onto AdapterRouter::with_trust would unify this." +kind = "openab_decision" +source = "crates/openab-core/src/adapter.rs#with_trust" + +[[quirks]] +date = "2026-07-04" +title = "files.upload sunset; separate 1 GB / 5 GB caps" +note = "files.upload sunsets 2025-11-12, replaced by files.getUploadURLExternal+files.completeUploadExternal; the 1 GB/file and 5 GB/free-workspace caps are separate, longstanding limits (the 5 GB cap dates to the 2019-03 changelog)." +kind = "intrinsic" +source = "https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/" + +[[quirks]] +date = "2026-07-04" +title = "Socket Mode needs no per-event signature validation" +note = "Socket Mode is pre-authenticated by the xapp- app-level token (sent in the Authorization header to apps.connections.open); inbound events need no per-event HMAC/signature validation — the docs state this explicitly, unlike the HTTP Events API." +kind = "intrinsic" +source = "https://docs.slack.dev/apis/events-api/using-socket-mode" + +[[quirks]] +date = "2026-07-04" +title = "Developer slash commands are HTTP-only, not usable in threads" +note = "Developer slash commands cannot be invoked in message threads and are delivered over HTTP only — matching the adapter's decision to ack-and-drop slash_commands envelopes." +kind = "intrinsic" +source = "https://docs.slack.dev/interactivity/implementing-slash-commands" + +[[quirks]] +date = "2026-07-04" +title = "chat.update / chat.delete own-message-only constraints" +note = "chat.update fails with cant_update_message on non-own messages and edit_window_closed under workspace edit settings (Tier 3); chat.delete with a bot token deletes only that bot's own messages — which is why OpenAB's delete uses a zero-width-space edit fallback rather than assuming cross-author delete." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/methods/chat.delete" + +[[quirks]] +date = "2026-07-04" +title = "chat.postMessage length limits (basis for 11,900 message_limit)" +note = "chat.postMessage limits: text recommended <=4,000 / truncated at 40,000; markdown_text (and the Block Kit markdown block) up to 12,000 — the basis for the 11,900 message_limit." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/methods/chat.postMessage" + +[[quirks]] +date = "2026-07-04" +title = "Other bots' messages delivered as bot_message events" +note = "Other bots' messages ARE delivered as message events with subtype: bot_message + bot_id; OpenAB gates them via allow_bot_messages and trusted_bot_ids." +kind = "intrinsic" +source = "https://docs.slack.dev/reference/events/message/bot_message/" + +[[quirks]] +date = "2026-07-04" +title = "Proactive posting rate limit (1 msg/sec/channel)" +note = "Proactive posting rate = no more than 1 message/second/channel (bursts tolerated; chat.postMessage is a Special Tier method); relevant to multibot loop caps." +kind = "intrinsic" +source = "https://docs.slack.dev/apis/web-api/rate-limits/" + +[[quirks]] +date = "2026-07-04" +title = "chat.stopStream appends rather than replaces" +note = "chat.stopStream appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-chat.update." +kind = "openab_decision" +source = "https://docs.slack.dev/reference/methods/chat.postMessage" +refs = ["#1055"] diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml new file mode 100644 index 000000000..6e77304e9 --- /dev/null +++ b/docs/platforms/schema/teams.toml @@ -0,0 +1,337 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — MICROSOFT TEAMS +# Generated from docs/platforms/teams.md. See _template.toml for the schema. +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-07" + +[platform] +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)." + + +# ═══ 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)." +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." +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." +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" + +[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`." +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_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" + +[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" + +[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" + +[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" + +[capability.attachments] +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" + +[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" + +[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" + +[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`." +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" + +[capability.send_model] +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" + +[capability.proactive_push] +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" + +[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" + +[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" + + +# ═══ 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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + +[[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 = "" + + +# ═══ 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" +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" +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" +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" +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" +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" +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" +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" +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" +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" + +[[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" +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" +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" +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" +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" +source = "crates/openab-gateway/src/adapters/teams.rs" diff --git a/docs/platforms/schema/telegram.toml b/docs/platforms/schema/telegram.toml new file mode 100644 index 000000000..4eb013a00 --- /dev/null +++ b/docs/platforms/schema/telegram.toml @@ -0,0 +1,353 @@ +# Generated from docs/platforms/telegram.md — see docs/platforms/README.md for schemas. + +schema_version = "2026-07-07" + +[platform] +name = "telegram" +official_docs = "https://core.telegram.org/bots/api" +description = "Telegram Bot API — webhook-delivered Update objects for bots in DMs, groups, supergroups (with forum topics), and channels." + + +# ═══ Schema 1 — platform-capability ══════════════════════════════════════════ + +[capability.transport] +kind = "webhook" +note = "JSON POST of `Update`; long-poll `getUpdates` also exists but OpenAB uses webhook." +source = "https://core.telegram.org/bots/api#getting-updates" + +[capability.inbound_auth] +scheme = "shared_secret" +note = "L1 only: `X-Telegram-Bot-Api-Secret-Token` header (opaque shared secret set on `setWebhook`) + optional source-IP allowlist (149.154.160.0/20, 91.108.4.0/22). No HMAC/body signature." +source = "https://core.telegram.org/bots/webhooks" + +[capability.threads] +model = "native" +note = "Native forum topics only: supergroups with topics enabled carry `message_thread_id`, created via `createForumTopic`. Non-forum chats have no threading (reply-to quoting exists separately)." +source = "https://core.telegram.org/bots/api#createforumtopic" + +[capability.slash_commands] +supported = true +note = "`/command` text arrives as a `bot_command` entity; discoverable list registered via `setMyCommands`. Command names ≤32 chars, Latin letters/digits/underscores. Delivery is just message text — the bot parses it." +source = "https://core.telegram.org/bots/api#setmycommands" + +[capability.mentions] +method = "username" +note = "Two entity types: `mention` (`@username`) and `text_mention` (users without a username, carries a `User`). Privacy mode ON limits which messages a group bot sees (commands meant for it, replies, service messages, etc.)." +source = "https://core.telegram.org/bots/api#messageentity" + +[capability.emoji_reactions] +bot_can_add = true +bot_can_remove = true +bot_receives_events = true +note = "Add/set via `setMessageReaction` (replaces the bot's whole reaction set; non-premium limited to one emoji from the allowed set); remove = set to empty. Receiving `message_reaction` requires the bot be a chat admin + explicit `message_reaction` in `allowed_updates`, and 'The update isn't received for reactions set by bots.'" +source = "https://core.telegram.org/bots/api#setmessagereaction" + +[capability.edit_message] +supported = true +note = "`editMessageText` (also `editMessageCaption`/`editMessageMedia`) on the bot's own messages." +source = "https://core.telegram.org/bots/api#editmessagetext" + +[capability.delete_message] +supported = true +scope = "own_and_others" +note = "`deleteMessage`: a bot can delete its own outgoing messages; others' only with admin `can_delete_messages`. A message can only be deleted if sent less than 48 hours ago (with narrow exceptions: service messages / a bot's own messages / channel/anonymous-admin cases)." +source = "https://core.telegram.org/bots/api#deletemessage" + +[capability.rich_content] +markdown = true +cards = false +buttons = true +note = "Markdown/MarkdownV2 + HTML `parse_mode`; inline keyboards / reply keyboards / callback buttons. Bot API 10.1 (2026-06-11) added `sendRichMessage` + `InputRichMessage` for GFM-style rich blocks (tables, headings, syntax-highlighted code) — see quirks. No legacy 'card' object beyond keyboards." +source = "https://core.telegram.org/bots/api#formatting-options" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 20 +note = "inbound: photo, document, voice, audio, video, etc.; outbound: photo/document/etc. Download via `getFile` capped at 20 MB on the cloud Bot API (up to 2 GB only with a self-hosted local Bot API server). Send caps: photos ~10 MB, other files 50 MB." +source = "https://core.telegram.org/bots/api#getfile" + +[capability.message_length_limit] +max_chars = 4096 +note = "`sendMessage` text: 1–4096 UTF-8 chars; captions 1024. Longer plain text must be chunked. Rich messages via `sendRichMessage` accept larger content — see quirks; exact ceiling ? (unverified from truncated docs)." +source = "https://core.telegram.org/bots/api#sendmessage" + +[capability.dm_support] +supported = true +note = "1:1 private chat; the user must `/start` the bot first — bots cannot initiate a private chat." +source = "https://core.telegram.org/bots/faq" + +[capability.group_model] +kinds = ["private", "group", "supergroup", "channel"] +note = "private (DM), group, supergroup (optionally forum = topics), channel. `chat.type` distinguishes them." +source = "https://core.telegram.org/bots/api#chat" + +[capability.group_sender_identity] +stable_id = "yes" +note = "Stable numeric `from.id` present on group messages (not consent-gated), unless sent on behalf of a chat (`sender_chat`). Privacy mode limits which messages the bot sees, not the identity of the ones it does." +source = "https://core.telegram.org/bots/api#message" + +[capability.send_model] +model = "push_only" +note = "Push model: the bot may send to any chat it shares with the user at any time (no reply-window/TTL). Reply/quote via `reply_parameters`. Rate-limited (see proactive_push)." +source = "https://core.telegram.org/bots/api#sendmessage" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Allowed to any user who has started the bot / any group it's in. Rate limits (per FAQ): avoid >1 msg/s per chat; bulk broadcast not more than ~30 msg/s overall; not more than 20 msg/min in a group. No reply-window gating." +source = "https://core.telegram.org/bots/faq" + +[capability.bot_to_bot] +delivered = false +note = "Per FAQ, 'Bots will not be able to see messages from other bots regardless of mode.' `is_bot` is present on `from` for the rare forwarded/quoted cases." +source = "https://core.telegram.org/bots/faq" + +[capability.typing_indicator] +supported = true +note = "`sendChatAction` (e.g. `typing`) auto-clears after ~5s or on next message. Not used by the OpenAB adapter." +source = "https://core.telegram.org/bots/api#sendchataction" + + +# ═══ Schema 2 — openab-feature-support ═══════════════════════════════════════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "`sendMessage` with `parse_mode: Markdown`; on a Markdown parse error (`is_markdown_parse_error`) retries once as plain text." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-gateway/src/adapters/telegram.rs#is_markdown_parse_error"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "`chunk_text` splits at 4096 chars, preferring newline boundaries; hard-splits a single over-limit line char-by-char. (Adapter-local, not the trait's `split_delivery`.)" +source = ["crates/openab-gateway/src/adapters/telegram.rs#chunk_text"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "partial" +note = "No legacy streaming API; streaming maps onto `editMessageText` when `reply_to` is a real message_id. When `reply_to == \"draft\"` and `rich_messages` is on, it calls `sendRichMessageDraft` (skips updates <30 chars, truncates at 32768 via `floor_char_boundary`). That draft fn `send_rich_message_draft` is `#[allow(dead_code)]` — 'Wired but unused until gateway streaming infrastructure integrates' — so the draft path is not yet driven by gateway streaming." +source = ["crates/openab-gateway/src/adapters/telegram.rs#send_rich_message_draft"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "partial" +note = "`GatewayReply` carries a `quote_message_id`, but the Telegram send path does not attach `reply_to_message_id`/`reply_parameters`; outbound only sets `message_thread_id` (forum-topic routing). So agent quote-replies aren't rendered as native quotes." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "implemented" +note = "`command == \"edit_message\"` → `editMessageText` (real msg_id) or, for a `\"draft\"` ref with `rich_messages` on, `sendRichMessageDraft`." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "No `deleteMessage` call anywhere in the adapter and no delete command handled in `handle_reply` (dispatch covers only create_topic / edit_message / reactions / send). Telegram supports it natively; simply not wired. The trait's default `delete_message` returns an unsupported error." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "workaround" +note = "`add_reaction`/`remove_reaction` → `setMessageReaction`. Because non-premium chats allow only ONE reaction, the adapter keeps a local `reaction_state` map and always sends the full replacing set; the mood-face emojis (😊😎🫡🤓😏✌️💪🦾) are dropped so the terminal 👍 'done' marker isn't clobbered, and 🆗→👍 is remapped." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "implemented" +note = "`command == \"create_topic\"` → `createForumTopic`, returns `message_thread_id` in a `GatewayResponse`. The trait's `create_thread` issues that command with a 5s timeout and falls back to the same channel on failure/timeout; core ingress only creates a topic for supergroups with no existing `thread_id`." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs#create_thread"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "implemented" +note = "photo (largest by `width*height`), document (text-only, extension-checked + UTF-8 validated), voice, audio downloaded via `getFile`; size-gated against `IMAGE/AUDIO/FILE_MAX_DOWNLOAD` (Content-Length pre-check and post-read); images resized/compressed; non-text or binary docs rejected with a reason." +source = ["crates/openab-gateway/src/adapters/telegram.rs#download_telegram_media", "crates/openab-gateway/src/media.rs#FILE_MAX_DOWNLOAD"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "partial" +note = "The adapter only downloads voice/audio as an `audio` attachment (`MediaKind::Audio`, `download_telegram_media`); it does no transcription. STT happens downstream in core: batched / per-message paths call `download_and_transcribe` when `stt_config.enabled`, injecting a `[Voice message transcript]:` block." +source = ["crates/openab-gateway/src/adapters/telegram.rs#download_telegram_media", "crates/openab-core/src/media.rs#download_and_transcribe"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "partial" +note = "Adapter enforces L1 only: `secret_token` header + optional Telegram source-subnet check (`telegram_trusted_source_only`; IP extraction is phase-1 'observe', trusting CF/X-Real-IP then the spoofable leftmost XFF). L2 scope / L3 identity allowlists are enforced by the shared core ingress trust gate, not the adapter." +source = ["crates/openab-gateway/src/adapters/telegram.rs#handle_reply", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "Not in the adapter — handled by core ingress: an L3 `DenyIdentity` decision echoes the sender their ID ('Your ID: … Ask the admin to add it to allowed_users'), throttled to one echo per (platform, sender) per `ECHO_WINDOW` (300s). `DenyScope` is a silent drop." +source = ["crates/openab-core/src/gateway.rs#ECHO_WINDOW"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "implemented" +note = "In groups/supergroups without a thread, core `should_skip_event` requires an `@bot_username` mention; in-thread events bypass the gate. The adapter extracts `mention` entities into `event.mentions`." +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/telegram.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "implemented" +note = "`/reset` and `/cancel` handled in core gateway (both the batched consumer path and the per-message path). The adapter has no per-command parsing beyond forwarding text." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "implemented" +note = "`should_skip_event` drops other bots' events unless the sender id is in `trusted_bot_ids`; the adapter forwards `is_bot` from `from`. (Telegram rarely delivers other bots' messages anyway.)" +source = ["crates/openab-core/src/gateway.rs#should_skip_event", "crates/openab-gateway/src/adapters/telegram.rs"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "implemented" +note = "Session keyed by `chat.id` + optional `message_thread_id`; `compute_draft_id` derives a stable per-(chat,thread) draft id to avoid forum-topic collisions." +source = ["crates/openab-gateway/src/adapters/telegram.rs#compute_draft_id"] +pr = "" + + +# ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ + +[[quirks]] +date = "2026-07-04" +title = "'Rich Message' API is real (Bot API 10.1) — but the adapter path is flag-gated and partly dead-code" +note = """ +The adapter's `sendRichMessage` / `sendRichMessageDraft` / `InputRichMessage.markdown` calls correspond to methods genuinely added in Bot API 10.1 (2026-06-11): 'Added the method `sendRichMessage`…', 'Added the method `sendRichMessageDraft`, allowing bots to stream partial rich messages', 'Added the class `InputRichMessage`…'. This corrects an earlier assumption that the path was fictional. Caveats that remain code-side, not API-side: +- The path is gated behind the adapter's `rich_messages` flag and falls back to legacy chunked `sendMessage` on any error (`is_complex_markdown` picks headings/GFM tables/over-4096-char text as candidates; code blocks are deliberately routed to legacy `sendMessage` to keep syntax highlighting). +- `send_rich_message_draft` is `#[allow(dead_code)]` and not yet driven by gateway streaming. +- The exact 32768-char limit and the precise `InputRichMessage` field names (`markdown` vs `html`) the adapter assumes could not be confirmed from the official page (method-body sections were truncated on fetch); confirm against a full 10.1 docs / Bot API server before relying on the 32768 truncation and the html/markdown branch. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api-changelog" + +[[quirks]] +date = "2026-07-04" +title = "Reactions are single-slot, non-additive" +note = """ +Telegram non-premium chats allow only one reaction per message. The adapter maintains `reaction_state` and always PUTs the full replacing set via `setMessageReaction`; multi-emoji 'mood' reactions used on Discord are deliberately dropped so the terminal 👍 isn't clobbered. Consequence: OpenAB's add/remove reaction semantics are emulated, not literal. Separately, inbound reaction events (`message_reaction`) require the bot be a chat admin + opt in via `allowed_updates`, and are never delivered for reactions set by bots — the adapter does not subscribe to them. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#setmessagereaction" + +[[quirks]] +date = "2026-07-04" +title = "Auth is L1-only and IP extraction is phase-1" +note = """ +Only `secret_token` + an optional source-subnet check gate the webhook. Subnet enforcement is opt-in (`telegram_trusted_source_only`), and IP extraction trusts `CF-Connecting-IP` / `X-Real-IP` then falls back to the spoofable leftmost `X-Forwarded-For` — a documented phase-2 gap. Real identity/scope trust lives in core ingress (L2 scope / L3 identity), and the deny-echo lives there too. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "20 MB inbound download ceiling" +note = """ +`getFile` on the cloud Bot API can't serve files >20 MB; large media will fail to download regardless of OpenAB's own `*_MAX_DOWNLOAD` limits (10/20/20 MB). A self-hosted local Bot API server is required for up to 2 GB. STT downstream imposes its own 25 MB (Whisper) cap in core. +""" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#getfile" + +[[quirks]] +date = "2026-07-04" +title = "Outbound quotes aren't native" +note = """ +`GatewayReply.quote_message_id` exists, but the Telegram send path never sets `reply_to_message_id`/`reply_parameters` — it only sets `message_thread_id`. Agent quote-replies therefore land in the right topic but aren't rendered as native Telegram quotes. +""" +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: Bot API 10.1 rich-message methods are real" +note = "Bot API 10.1 (2026-06-11) added `sendRichMessage`, `sendRichMessageDraft`, and `InputRichMessage` — the adapter's 'rich' path targets a REAL API, not a fictional one (corrects prior draft). Exact 32768 limit / field names still unverified (docs truncated on fetch)." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api-changelog" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: getFile 20 MB cloud cap" +note = "Telegram cloud `getFile` caps downloads at 20 MB; up to 2 GB needs a self-hosted local Bot API server." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#getfile" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: deleteMessage 48h window" +note = "`deleteMessage` generally only works within 48h for a bot's outgoing messages; deleting others' needs admin `can_delete_messages`." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#deletemessage" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: message_reaction delivery constraints" +note = "`message_reaction` updates require the bot be a chat admin + opt-in via `allowed_updates`, and 'The update isn't received for reactions set by bots.'" +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#update" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: sendMessage 4096 char limit" +note = "`sendMessage` text limit is 1–4096 UTF-8 chars; adapter chunks at 4096." +kind = "intrinsic" +source = "https://core.telegram.org/bots/api#sendmessage" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: broadcasting rate limits" +note = "Broadcasting limits: ~1 msg/s per chat, ~30 msg/s overall bulk, 20 msg/min per group." +kind = "intrinsic" +source = "https://core.telegram.org/bots/faq" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: privacy mode group visibility" +note = "With privacy mode ON, a group bot receives only `/cmd@bot` commands, general commands if it messaged last, replies to it, inline messages via it, and all service messages." +kind = "intrinsic" +source = "https://core.telegram.org/bots/features#privacy-mode" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: delete_message unimplemented despite native support" +note = "`delete_message` is unimplemented in the Telegram adapter despite native support — no `deleteMessage` call or command handler; trait default returns unsupported." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" +refs = [] + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: outbound send never sets reply_to_message_id" +note = "Outbound send sets `message_thread_id` for topic routing but never `reply_to_message_id`, so agent quote-replies aren't native quotes." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/telegram.rs#handle_reply" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: MAX_DOWNLOAD constants live in gateway crate" +note = "MAX_DOWNLOAD constants (IMAGE 10 / FILE 20 / AUDIO 20 MB) live in the gateway crate, not core; core adds a separate 25 MB Whisper STT cap." +kind = "openab_decision" +source = "crates/openab-gateway/src/media.rs#FILE_MAX_DOWNLOAD" + +[[quirks]] +date = "2026-07-04" +title = "Findings-log: STT done in core, not the adapter" +note = "Voice/audio STT is not done in the adapter; core transcribes `audio` attachments via `download_and_transcribe` when `stt_config.enabled`." +kind = "openab_decision" +source = "crates/openab-core/src/media.rs#download_and_transcribe" diff --git a/docs/platforms/schema/wecom.toml b/docs/platforms/schema/wecom.toml new file mode 100644 index 000000000..074f8e92f --- /dev/null +++ b/docs/platforms/schema/wecom.toml @@ -0,0 +1,307 @@ +# ═══════════════════════════════════════════════════════════════════════════ +# Platform capability schema — WECOM (WeCom / WeChat Work) +# ═══════════════════════════════════════════════════════════════════════════ + +schema_version = "2026-07-07" + +[platform] +name = "wecom" +official_docs = "https://developer.work.weixin.qq.com/document/path/90664" +description = "WeCom (企業微信 / WeChat Work) self-built app (自建应用 / agentid) callback integration — AES-encrypted 1:1 member callbacks, proactive push via message/send." + + +# ═══ Schema 1 — platform-capability (source of truth: official docs) ═════════ + +[capability.transport] +kind = "webhook" +note = "HTTP callback: WeCom POSTs AES-encrypted XML to the configured self-built-app callback URL." +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[capability.inbound_auth] +scheme = "aes" +note = "L1: msg_signature = SHA1 of sort(token, timestamp, nonce, encrypt).concat(); body is AES-256-CBC decrypted with the 43-char EncodingAESKey (base64→32 bytes), IV = first 16 key bytes, WeCom PKCS7 block_size=32; inner corp_id suffix validated." +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[capability.threads] +model = "none" +note = "Self-built app callback is a flat 1:1 conversation; no thread/topic primitive." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.slash_commands] +supported = false +note = "Not a platform primitive. No command registration/delivery; text like /reset arrives as ordinary text content and any interpretation is OpenAB-side." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.mentions] +method = "none" +note = "n/a in the self-built app 1:1 model — every callback is a direct message from one FromUserName; no @mention concept. (Group @mention exists only under the separate 智能机器人 model, which this adapter does not use.)" +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.emoji_reactions] +bot_can_add = false +bot_can_remove = false +bot_receives_events = false +note = "No reaction API for self-built apps: bot cannot add or remove reactions, and reaction events are not delivered. The receive doc enumerates only six inbound callback types: text, image, voice, video, location, link (no reaction, no edit)." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.edit_message] +supported = false +note = "No edit API. The only mutation is recall (/cgi-bin/message/recall), which deletes rather than edits, and only within 24h on messages this app sent." +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[capability.delete_message] +supported = true +scope = "own" +note = "Own messages only, via recall (/cgi-bin/message/recall), within 24 hours of send; cannot delete users'/others' messages (data already delivered to the WeChat-plugin end also can't be pulled back)." +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[capability.rich_content] +markdown = true +cards = true +buttons = false +note = "Supported outbound msgtypes: text, image, voice, video, file, textcard, news (图文), mpnews, markdown, miniprogram_notice, template_card. Adapter uses only text." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.attachments] +inbound = ["image", "audio", "video", "file"] +outbound = ["image", "audio", "video", "file"] +max_size_mb = 20 +note = "Outbound media upload caps (media/upload): image ≤10MB (JPG/PNG), voice ≤2MB/60s (AMR), video ≤10MB (MP4), general file ≤20MB; min 5 bytes; temp media_id valid 3 days. Inbound: image/voice/video callbacks carry a MediaId pulled via media/get (same 3-day validity). Note: the receive doc's six enumerated inbound types are text/image/voice/video/location/link — inbound file (MediaId + FileName) is not in that list yet is delivered in practice and handled by the adapter." +source = "https://developer.work.weixin.qq.com/document/path/90253" + +[capability.message_length_limit] +max_chars = 2048 +note = "text content ≤ 2048 bytes (超过将截断 — server truncates; ~680 CJK chars at 3 bytes each). Counting unit is UTF-8 bytes, not chars. Chunking required for long replies." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.dm_support] +supported = true +note = "Yes — the self-built app model is 1:1 (app ↔ member via touser)." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.group_model] +kinds = ["user"] +note = "Self-built app has no group callback. A separate appchat (群聊) send API and the 智能机器人 model exist but are not used by this adapter." +source = "https://developer.work.weixin.qq.com/document/path/90248" + +[capability.group_sender_identity] +stable_id = "yes" +note = "n/a for group in this adapter (1:1 only). In 1:1, the stable sender id is FromUserName (member UserID), always present, no consent gate." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.send_model] +model = "push_only" +reply_token_ttl_sec = 0 +max_objects_per_send = 0 +note = "Push — app calls message/send with access_token + agentid + touser. No reply-window / reply-token; a user need not have messaged first." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.proactive_push] +supported = true +quota_model = "metered" +note = "Allowed and unsolicited. Quotas: per app ≤ (account cap × 200) person-times/day; per app→member ≤ 30/min and 1000/hour (excess 会被丢弃不下发 — silently dropped)." +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[capability.bot_to_bot] +delivered = false +note = "n/a — self-built app callbacks originate from human members (FromUserName UserID); the platform does not deliver other apps'/bots' messages to a self-built app." +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[capability.typing_indicator] +supported = false +note = "Not supported by the API." +source = "https://developer.work.weixin.qq.com/document/path/90236" + + +# ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ + +[[openab_features]] +feature = "send_message" +status = "implemented" +note = "message/send msgtype=text with agentid + touser; token cached & auto-refreshed, retries once on errcode 42001." +source = ["crates/openab-gateway/src/adapters/wecom.rs#send_text", "crates/openab-gateway/src/adapters/wecom.rs#post_with_token_retry"] +pr = "" + +[[openab_features]] +feature = "message_split" +status = "implemented" +note = "Byte-aware split at 2048 (WeCom's server-side byte cap); prefers \\n boundaries, splits over-long lines at UTF-8 char boundaries (char_indices) so multibyte chars aren't severed. Uses local split_text_lines, not the trait split_delivery." +source = ["crates/openab-gateway/src/adapters/wecom.rs#split_text_lines", "crates/openab-core/src/adapter.rs#split_delivery"] +pr = "" + +[[openab_features]] +feature = "streaming" +status = "workaround" +note = "No edit API in callback mode, so streaming = optional ⏳... placeholder + debounce-buffer chunks into a watch channel + recall placeholder + resend consolidated text (flush_thinking). Causes client flicker → default OFF (WECOM_STREAMING_ENABLED, debounce_secs=3). With streaming off, chunks buffer silently and one consolidated message is sent." +source = ["crates/openab-gateway/src/adapters/wecom.rs#flush_thinking"] +pr = "" + +[[openab_features]] +feature = "reply_quote" +status = "n_a" +note = "No reply/quote primitive in the self-built app model. The trait send_message_with_reply default just falls back to plain send; adapter never wires it." +source = ["crates/openab-core/src/adapter.rs#send_message_with_reply"] +pr = "" + +[[openab_features]] +feature = "edit_message" +status = "workaround" +note = "reply.command == edit_message only pushes new text into an in-flight streaming watch channel (handle_edit_message); there is no real WeCom edit. Outside a pending stream it is a no-op. The trait edit_message default (returns not supported) otherwise applies." +source = ["crates/openab-gateway/src/adapters/wecom.rs#handle_edit_message", "crates/openab-core/src/adapter.rs#edit_message"] +pr = "" + +[[openab_features]] +feature = "delete_message" +status = "not_implemented" +note = "Recall API exists (24h window) but adapter never calls /cgi-bin/message/recall for user-facing delete — only internally to remove the thinking placeholder. Trait delete_message default edits to zero-width space, which WeCom can't honor." +source = ["crates/openab-gateway/src/adapters/wecom.rs#flush_thinking", "crates/openab-core/src/adapter.rs#delete_message"] +pr = "" + +[[openab_features]] +feature = "emoji_reactions" +status = "n_a" +note = "reply.command add_reaction / remove_reaction are explicitly matched and ignored with a log line — WeCom self-built apps have no reaction API." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "threads_topics" +status = "n_a" +note = "create_topic command explicitly ignored (logged). No thread primitive on platform." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "media_inbound" +status = "partial" +note = "Inbound image → download over HTTPS-only (SSRF guard), reject >10MB, resize ≤1200px + JPEG q75 (GIF passthrough). file → media/get (retry on 42001), reject >20MB, text files only (extension/filename allowlist) and must be valid UTF-8; binary/office files rejected. voice/video/location/link msgtypes are dropped (only text/image/file forwarded)." +source = ["crates/openab-gateway/src/adapters/wecom.rs#download_wecom_image", "crates/openab-gateway/src/adapters/wecom.rs#fetch_media_with_retry", "crates/openab-gateway/src/adapters/wecom.rs#download_wecom_file", "crates/openab-gateway/src/adapters/wecom.rs#is_text_file", "crates/openab-gateway/src/adapters/wecom.rs#resize_and_compress"] +pr = "" + +[[openab_features]] +feature = "voice_stt" +status = "not_implemented" +note = "voice msgtype is not in the accepted set (text|image|file); voice callbacks are dropped, no STT." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "trust_gate" +status = "implemented" +note = "Shared. Gateway ingress gate gate_incoming (L2 scope + L3 identity) runs before dispatch; wecom events carry sender.id = FromUserName (UserID) and channel.id = wecom:{corp_id}:{from_user} for keying." +source = ["crates/openab-gateway/src/adapters/wecom.rs", "crates/openab-core/src/gateway.rs", "crates/openab-core/src/adapter.rs#gate_incoming"] +pr = "" + +[[openab_features]] +feature = "deny_echo" +status = "implemented" +note = "Shared. On DenyIdentity the gateway echoes the sender their UserID with a request-access hint (throttled via echo_allowed); DenyScope silently drops. Platform-agnostic path, applies to wecom replies." +source = ["crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "mention_gating" +status = "n_a" +note = "Shared @mention gating only fires for group/supergroup channel_type; wecom events are channel_type=direct, so gating is bypassed by design." +source = ["crates/openab-gateway/src/adapters/wecom.rs", "crates/openab-core/src/gateway.rs"] +pr = "" + +[[openab_features]] +feature = "slash_commands" +status = "partial" +note = "Shared. No platform slash mechanism; commands arrive as plain text and are handled by OpenAB's generic command layer, not in the wecom adapter." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "multibot" +status = "n_a" +note = "Self-built app 1:1 callbacks come only from human members (is_bot: false); no other-bot delivery, no multi-bot channel." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + +[[openab_features]] +feature = "group_routing" +status = "partial" +note = "Sessions keyed by wecom:{corp_id}:{from_user} (per-user 1:1); no group routing since there are no group callbacks in this model." +source = ["crates/openab-gateway/src/adapters/wecom.rs"] +pr = "" + + +# ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ + +[[quirks]] +date = "2026-07-04" +title = "Send / push model (no reply window)" +note = "WeCom self-built apps are pure push: given a valid access_token + agentid, the app can message any member via touser at any time — there is no LINE-style reply token or reply window. access_token (7200s TTL) is cached with a 300s refresh margin (TOKEN_REFRESH_MARGIN_SECS) and force-refreshed on errcode 42001 across both message/send and media/get." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[[quirks]] +date = "2026-07-04" +title = "Crypto / callback specifics" +note = """ +EncodingAESKey is 43 base64 chars without padding; adapter appends = and decodes with Indifferent padding + allow_trailing_bits (the 43rd char's last 2 bits are not payload). Result must be exactly 32 bytes (decode_aes_key). +WeCom uses PKCS7 with block_size=32 (not 16); adapter decrypts AES-256-CBC with NoPadding and strips padding manually (pad value 1–32). Plaintext = random(16) + msg_len(4 BE) + msg + corp_id; inner corp_id must equal configured CORP_ID. IV = first 16 key bytes. +Defense-in-depth: outer envelope ToUserName must equal CORP_ID; msg_signature compared in constant time (subtle::ConstantTimeEq); stale callbacks (>300s timestamp skew) rejected; 30s TTL / 10k-entry dedupe cache on MsgId absorbs WeCom's ~5s retries. +gettoken requires corpsecret as a query param (protocol-mandated) — operators must redact query strings on /cgi-bin/gettoken in proxy logs; gateway never logs that URL. +""" +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[[quirks]] +date = "2026-07-04" +title = "\"Streaming\" is recall + resend" +note = "Because callback mode has no message-edit API, streaming is emulated: optional ⏳... placeholder, debounce-buffer deltas into a tokio::sync::watch channel (default 3s), then recall the placeholder and send the consolidated final text via flush_thinking. This flickers, so it is off by default (WECOM_STREAMING_ENABLED). With it off, deltas are buffered silently and one message is sent when the debounce settles — no flicker, no recall. A 300s idle cap on the debounce task prevents an orphaned pending entry." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs#flush_thinking" + +[[quirks]] +date = "2026-07-04" +title = "Inbound filtering is aggressive" +note = "Only text, image, file msgtypes are forwarded; voice / video / location / link are dropped. Files must pass a text-extension/filename allowlist AND be valid UTF-8 — office/binary files are rejected (no doc parsing). Images are HTTPS-only, ≤10MB, downscaled to ≤1200px JPEG q75 (GIF passthrough). Placeholder prompts are injected for media: image → \"Describe this image.\", file → \"User sent a file: {name}\"." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs#is_text_file" + +[[quirks]] +date = "2026-07-04" +title = "Inbound types: six enumerated, file delivered anyway" +note = "Self-built app receive doc enumerates six inbound types: text/image/voice/video/location/link — file is not listed there (yet delivered & handled by the adapter). FromUserName = member UserID, MsgId = 64-bit; no group/@mention/reaction/edit in this model." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90239" + +[[quirks]] +date = "2026-07-04" +title = "Text cap and push quotas" +note = "text content capped at 2048 bytes (truncated); push quota per app→member 30/min, 1000/hr, per-app (account-cap×200)/day, excess silently dropped. msgtypes: text/image/voice/video/file/textcard/news/mpnews/markdown/miniprogram_notice/template_card." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90236" + +[[quirks]] +date = "2026-07-04" +title = "No edit API; recall only" +note = "No edit API; only recall (/cgi-bin/message/recall) within 24h on this app's own messages (delete, not edit; WeChat-plugin-end data can't be pulled back)." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/94867" + +[[quirks]] +date = "2026-07-04" +title = "Media caps" +note = "Media caps: image 10MB (JPG/PNG), voice 2MB/60s (AMR), video 10MB (MP4), file 20MB; min 5 bytes; temp media_id valid 3 days." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90253" + +[[quirks]] +date = "2026-07-04" +title = "Callback auth summary" +note = "Callback auth = SHA1 msg_signature (sorted token/ts/nonce/encrypt) + AES-256-CBC via 43-char EncodingAESKey, PKCS7 block_size=32, IV = first 16 key bytes." +kind = "intrinsic" +source = "https://developer.work.weixin.qq.com/document/path/90238" + +[[quirks]] +date = "2026-07-04" +title = "Section-2 refs verified; WecomAdapter is a standalone handler" +note = "All section-2 refs verified against the tree; WecomAdapter is a standalone handler (does not impl ChatAdapter); trait defaults referenced (split_delivery, send_message_with_reply, edit_message, delete_message, gate_incoming) live in openab-core/src/adapter.rs." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/wecom.rs" +refs = [] From b9b0af94b62f324539e69c906acf5b6f0d973bc0 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Wed, 8 Jul 2026 01:30:00 +0000 Subject: [PATCH 28/37] refactor(platform-schema): serde structs as the authoritative schema Switch the validator from toml_edit tree-walking back to serde-derive structs (the agreed design in #1322): each schema/*.toml deserializes into `Platform`, with enums as closed sets and `deny_unknown_fields` for typo rejection. Conformance tests unchanged in intent (structural validity, schema version, closed feature set, present-features-cite-a-source, and the anti-drift check that every code-ref source still exists). 4 unit + 8 conformance tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workflows/platform-schema-conformance.yml | 4 +- crates/platform-schema/Cargo.lock | 103 ++- crates/platform-schema/Cargo.toml | 10 +- crates/platform-schema/src/lib.rs | 734 +++++++++--------- crates/platform-schema/tests/conformance.rs | 123 ++- docs/platforms/_template.toml | 2 +- 6 files changed, 550 insertions(+), 426 deletions(-) diff --git a/.github/workflows/platform-schema-conformance.yml b/.github/workflows/platform-schema-conformance.yml index 07b509809..2e78e056a 100644 --- a/.github/workflows/platform-schema-conformance.yml +++ b/.github/workflows/platform-schema-conformance.yml @@ -22,6 +22,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - # Dependency-light (toml_edit parse-only: no proc-macros, no build - # scripts) so this is fast and needs no extra system packages. + # Standalone crate (serde + toml only); excluded from the root workspace + # so it builds without the heavy adapter crates. - run: cargo test --manifest-path crates/platform-schema/Cargo.toml diff --git a/crates/platform-schema/Cargo.lock b/crates/platform-schema/Cargo.lock index 020baaee8..d709abdd4 100644 --- a/crates/platform-schema/Cargo.lock +++ b/crates/platform-schema/Cargo.lock @@ -26,14 +26,95 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "platform-schema" version = "0.1.0" dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", "toml_edit", ] @@ -42,6 +123,9 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_edit" @@ -50,10 +134,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", + "serde", + "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + [[package]] name = "winnow" version = "0.7.15" diff --git a/crates/platform-schema/Cargo.toml b/crates/platform-schema/Cargo.toml index d9d10a84f..8737f3d60 100644 --- a/crates/platform-schema/Cargo.toml +++ b/crates/platform-schema/Cargo.toml @@ -3,14 +3,14 @@ name = "platform-schema" version = "0.1.0" edition = "2021" publish = false -description = "Schema definition + conformance tests for docs/platforms/schema/*.toml" +description = "Authoritative types + conformance tests for docs/platforms/schema/*.toml" # Standalone crate (excluded from the root workspace) so it builds independently -# of the heavy adapter crates. Deliberately dependency-light: toml_edit's -# parse-only mode pulls no proc-macros and no build scripts, so `cargo test` -# needs no C toolchain — it runs anywhere, including minimal CI images. +# of the heavy adapter crates. The serde structs below ARE the schema; each +# schema/.toml deserializes into `Platform`. # # Run: cargo test (from this directory) [dependencies] -toml_edit = { version = "0.22", default-features = false, features = ["parse"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" diff --git a/crates/platform-schema/src/lib.rs b/crates/platform-schema/src/lib.rs index 9b6e487f0..2160a213b 100644 --- a/crates/platform-schema/src/lib.rs +++ b/crates/platform-schema/src/lib.rs @@ -1,16 +1,14 @@ -//! Schema definition + validation for `docs/platforms/schema/*.toml`. +//! Platform capability schema — authoritative types for `docs/platforms/schema/*.toml`. //! -//! The blank template and human-readable field docs live in -//! `docs/platforms/_template.toml`; this crate is the machine-enforced side. -//! [`validate`] checks one parsed file against the schema (required fields, -//! closed enum sets, the closed feature set, unknown-key rejection), and -//! [`feature_code_refs`] / [`quirk_code_refs`] surface the `source` code-refs so -//! the conformance tests can prove they still exist in the tree. +//! Each `schema/.toml` deserializes into [`Platform`]. The conformance +//! tests in `tests/conformance.rs` validate structure, enums, the closed feature +//! set, and that every code-ref `source` still exists in the tree. //! -//! Parsing uses `toml_edit` in parse-only mode: no proc-macros, no build -//! scripts, so the whole crate compiles and tests with no C toolchain. +//! The blank template + human-readable field docs live in +//! `docs/platforms/_template.toml`; keep the two in sync (a conformance test +//! checks the template still enumerates every section + feature key). -use toml_edit::{DocumentMut, Item, Table}; +use serde::Deserialize; /// Current schema version. Bump when the schema changes; stale files are flagged. pub const SCHEMA_VERSION: &str = "2026-07-07"; @@ -36,57 +34,7 @@ pub const EXPECTED_FEATURES: &[&str] = &[ "group_routing", ]; -/// The allowed `status` values for an OpenAB feature. -pub const FEATURE_STATUS: &[&str] = &[ - "implemented", - "partial", - "workaround", - "not_implemented", - "n_a", -]; - -/// Statuses that claim the feature is present, so they must cite a source. -const STATUS_NEEDS_SOURCE: &[&str] = &["implemented", "partial", "workaround"]; - -// ─── capability section specs ─────────────────────────────────────────────── -// Each field: (name, kind). Kind drives the type/enum check. Every capability -// section also implicitly requires `note` (string) + `source` (string), added -// automatically, so specs below list only the section-specific fields. - -enum Kind { - Bool, - Uint, - OptUint, - StrArray, - /// String value constrained to a closed set. - Enum(&'static [&'static str]), - /// Array whose every element is constrained to a closed set. - EnumArray(&'static [&'static str]), -} - -struct Spec { - section: &'static str, - fields: &'static [(&'static str, Kind)], -} - -const TRANSPORT: &[&str] = &["webhook", "websocket", "socket_mode", "long_poll"]; -const AUTH: &[&str] = &[ - "hmac_sha256", - "jwt_rs256", - "aes", - "shared_secret", - "oauth", - "none", -]; -const THREADS: &[&str] = &["native", "reply_to_only", "emulated", "none"]; -const MENTION: &[&str] = &["at_mention", "username", "self_flag", "none"]; -const DELETE_SCOPE: &[&str] = &["none", "own", "others", "own_and_others"]; -const ATTACH: &[&str] = &["image", "audio", "video", "file"]; -const STABLE_ID: &[&str] = &["yes", "no", "consent_gated"]; -const SEND_MODEL: &[&str] = &["any_time", "reply_only", "push_only", "hybrid"]; -const QUOTA: &[&str] = &["unlimited", "metered", "none"]; - -/// Every `[capability.*]` sub-section in template order. +/// Every `[capability.*]` sub-section name, in template order. pub const CAPABILITY_SECTIONS: &[&str] = &[ "transport", "inbound_auth", @@ -108,257 +56,353 @@ pub const CAPABILITY_SECTIONS: &[&str] = &[ "typing_indicator", ]; -fn capability_specs() -> &'static [Spec] { - use Kind::*; - &[ - Spec { section: "transport", fields: &[("kind", Enum(TRANSPORT))] }, - Spec { section: "inbound_auth", fields: &[("scheme", Enum(AUTH))] }, - Spec { section: "threads", fields: &[("model", Enum(THREADS))] }, - Spec { section: "slash_commands", fields: &[("supported", Bool)] }, - Spec { section: "mentions", fields: &[("method", Enum(MENTION))] }, - Spec { - section: "emoji_reactions", - fields: &[ - ("bot_can_add", Bool), - ("bot_can_remove", Bool), - ("bot_receives_events", Bool), - ], - }, - Spec { section: "edit_message", fields: &[("supported", Bool)] }, - Spec { - section: "delete_message", - fields: &[("supported", Bool), ("scope", Enum(DELETE_SCOPE))], - }, - Spec { - section: "rich_content", - fields: &[("markdown", Bool), ("cards", Bool), ("buttons", Bool)], - }, - Spec { - section: "attachments", - fields: &[ - ("inbound", EnumArray(ATTACH)), - ("outbound", EnumArray(ATTACH)), - ("max_size_mb", OptUint), - ], - }, - Spec { section: "message_length_limit", fields: &[("max_chars", Uint)] }, - Spec { section: "dm_support", fields: &[("supported", Bool)] }, - Spec { section: "group_model", fields: &[("kinds", StrArray)] }, - Spec { section: "group_sender_identity", fields: &[("stable_id", Enum(STABLE_ID))] }, - Spec { - section: "send_model", - fields: &[ - ("model", Enum(SEND_MODEL)), - ("reply_token_ttl_sec", OptUint), - ("max_objects_per_send", OptUint), - ], - }, - Spec { - section: "proactive_push", - fields: &[("supported", Bool), ("quota_model", Enum(QUOTA))], - }, - Spec { section: "bot_to_bot", fields: &[("delivered", Bool)] }, - Spec { section: "typing_indicator", fields: &[("supported", Bool)] }, - ] -} - -// ─── validation ───────────────────────────────────────────────────────────── - -/// Validate one parsed schema file (named `name`, e.g. "line") against the -/// schema. Returns a list of human-readable errors; empty means conforming. -pub fn validate(doc: &DocumentMut, name: &str) -> Vec { - let mut e = Vec::new(); - let root = doc.as_table(); - - // top-level: schema_version + platform + capability + arrays - check_unknown_keys( - root, - &["schema_version", "platform", "capability", "openab_features", "quirks"], - "(top level)", - &mut e, - ); - - match root.get("schema_version").and_then(Item::as_str) { - Some(v) if v == SCHEMA_VERSION => {} - Some(v) => e.push(format!("schema_version is {v:?}, expected {SCHEMA_VERSION:?} (stale)")), - None => e.push("missing schema_version (string)".into()), - } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Platform { + pub schema_version: String, + pub platform: Identity, + pub capability: Capability, + #[serde(default)] + pub openab_features: Vec, + #[serde(default)] + pub quirks: Vec, +} - validate_platform(root, name, &mut e); - validate_capability(root, &mut e); - validate_features(root, &mut e); - validate_quirks(root, &mut e); - e -} - -fn validate_platform(root: &Table, name: &str, e: &mut Vec) { - let Some(t) = root.get("platform").and_then(Item::as_table) else { - e.push("missing [platform] table".into()); - return; - }; - check_unknown_keys(t, &["name", "official_docs", "description"], "[platform]", e); - req_str(t, "name", "[platform]", e); - req_str(t, "official_docs", "[platform]", e); - req_str(t, "description", "[platform]", e); - if let Some(n) = t.get("name").and_then(Item::as_str) { - if n != name { - e.push(format!("[platform].name is {n:?} but must match filename {name:?}")); - } - } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Identity { + pub name: String, + pub official_docs: String, + pub description: String, } -fn validate_capability(root: &Table, e: &mut Vec) { - let Some(cap) = root.get("capability").and_then(Item::as_table) else { - e.push("missing [capability] table".into()); - return; - }; - let known: Vec<&str> = CAPABILITY_SECTIONS.to_vec(); - check_unknown_keys(cap, &known, "[capability]", e); - - for spec in capability_specs() { - let ctx = format!("[capability.{}]", spec.section); - let Some(t) = cap.get(spec.section).and_then(Item::as_table) else { - e.push(format!("missing {ctx}")); - continue; - }; - // allowed keys = spec fields + note + source - let mut allowed: Vec<&str> = spec.fields.iter().map(|(n, _)| *n).collect(); - allowed.push("note"); - allowed.push("source"); - check_unknown_keys(t, &allowed, &ctx, e); - - for (field, kind) in spec.fields { - check_field(t, field, kind, &ctx, e); - } - req_str(t, "note", &ctx, e); - req_str(t, "source", &ctx, e); - } +// ─── Schema 1 — platform-capability ───────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Capability { + pub transport: Transport, + pub inbound_auth: InboundAuth, + pub threads: Threads, + pub slash_commands: SlashCommands, + pub mentions: Mentions, + pub emoji_reactions: EmojiReactions, + pub edit_message: EditMessage, + pub delete_message: DeleteMessage, + pub rich_content: RichContent, + pub attachments: Attachments, + pub message_length_limit: MessageLengthLimit, + pub dm_support: DmSupport, + pub group_model: GroupModel, + pub group_sender_identity: GroupSenderIdentity, + pub send_model: SendModel, + pub proactive_push: ProactivePush, + pub bot_to_bot: BotToBot, + pub typing_indicator: TypingIndicator, } -fn validate_features(root: &Table, e: &mut Vec) { - let Some(arr) = root.get("openab_features").and_then(Item::as_array_of_tables) else { - e.push("missing [[openab_features]] (must have all 16)".into()); - return; - }; - let mut seen: Vec = Vec::new(); - for t in arr.iter() { - let ctx = "[[openab_features]]"; - check_unknown_keys(t, &["feature", "status", "note", "source", "pr"], ctx, e); - let feat = t.get("feature").and_then(Item::as_str); - match feat { - Some(f) if EXPECTED_FEATURES.contains(&f) => { - if seen.iter().any(|s| s == f) { - e.push(format!("duplicate feature {f:?}")); - } - seen.push(f.to_string()); - } - Some(f) => e.push(format!("unknown feature key {f:?}")), - None => e.push("feature block missing `feature` (string)".into()), - } - let fctx = feat.map(|f| format!("feature {f:?}")).unwrap_or_else(|| ctx.into()); - - let status = t.get("status").and_then(Item::as_str); - match status { - Some(s) if FEATURE_STATUS.contains(&s) => {} - Some(s) => e.push(format!("{fctx}: invalid status {s:?}")), - None => e.push(format!("{fctx}: missing status")), - } - req_str(t, "note", &fctx, e); - // source: array of strings (code refs) - let srcs = str_array(t, "source"); - if srcs.is_none() { - e.push(format!("{fctx}: `source` must be an array of strings")); - } - if let (Some(s), Some(list)) = (status, &srcs) { - if STATUS_NEEDS_SOURCE.contains(&s) && list.is_empty() { - e.push(format!("{fctx}: status {s:?} must cite at least one source")); - } - } - // pr optional string - if let Some(pr) = t.get("pr") { - if !pr.is_none() && pr.as_str().is_none() { - e.push(format!("{fctx}: `pr` must be a string")); - } - } - } - // closed-set completeness - for want in EXPECTED_FEATURES { - if !seen.iter().any(|s| s == want) { - e.push(format!("missing feature block {want:?}")); - } - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportKind { + Webhook, + Websocket, + SocketMode, + LongPoll, } -fn validate_quirks(root: &Table, e: &mut Vec) { - // quirks optional as a whole, but if present each block is validated. - let Some(item) = root.get("quirks") else { return }; - let Some(arr) = item.as_array_of_tables() else { - e.push("[[quirks]] must be an array of tables".into()); - return; - }; - for t in arr.iter() { - let title = t.get("title").and_then(Item::as_str).unwrap_or(""); - let ctx = format!("quirk {title:?}"); - check_unknown_keys(t, &["date", "title", "note", "kind", "source", "refs"], &ctx, e); - req_str(t, "date", &ctx, e); - req_str(t, "title", &ctx, e); - req_str(t, "note", &ctx, e); - match t.get("kind").and_then(Item::as_str) { - Some(k) if k == "intrinsic" || k == "openab_decision" => {} - Some(k) => e.push(format!("{ctx}: invalid kind {k:?} (intrinsic|openab_decision)")), - None => e.push(format!("{ctx}: missing kind")), - } - if let Some(src) = t.get("source") { - if !src.is_none() && src.as_str().is_none() { - e.push(format!("{ctx}: `source` must be a string")); - } - } - if let Some(r) = t.get("refs") { - if !r.is_none() && str_array(t, "refs").is_none() { - e.push(format!("{ctx}: `refs` must be an array of strings")); - } - } - } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Transport { + pub kind: TransportKind, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthScheme { + HmacSha256, + JwtRs256, + Aes, + SharedSecret, + Oauth, + None, } -// ─── code-ref extraction (for the existence tests) ────────────────────────── +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InboundAuth { + pub scheme: AuthScheme, + pub note: String, + pub source: String, +} -/// (context, ref) for every code-ref in `[[openab_features]].source`. -pub fn feature_code_refs(doc: &DocumentMut) -> Vec<(String, String)> { - let mut out = Vec::new(); - if let Some(arr) = doc.as_table().get("openab_features").and_then(Item::as_array_of_tables) { - for t in arr.iter() { - let feat = t.get("feature").and_then(Item::as_str).unwrap_or("?"); - for s in str_array(t, "source").unwrap_or_default() { - out.push((format!("feature {feat:?}"), s)); - } - } - } - out -} - -/// (context, ref) for every code-ref in a quirk `source` (URLs skipped). -pub fn quirk_code_refs(doc: &DocumentMut) -> Vec<(String, String)> { - let mut out = Vec::new(); - if let Some(arr) = doc.as_table().get("quirks").and_then(Item::as_array_of_tables) { - for t in arr.iter() { - let title = t.get("title").and_then(Item::as_str).unwrap_or("?"); - if let Some(s) = t.get("source").and_then(Item::as_str) { - if is_code_ref(s) { - out.push((format!("quirk {title:?}"), s.to_string())); - } - } - } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThreadModel { + Native, + ReplyToOnly, + Emulated, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Threads { + pub model: ThreadModel, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SlashCommands { + pub supported: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MentionMethod { + AtMention, + Username, + SelfFlag, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Mentions { + pub method: MentionMethod, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmojiReactions { + pub bot_can_add: bool, + pub bot_can_remove: bool, + pub bot_receives_events: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EditMessage { + pub supported: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeleteScope { + None, + Own, + Others, + OwnAndOthers, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteMessage { + pub supported: bool, + pub scope: DeleteScope, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RichContent { + pub markdown: bool, + pub cards: bool, + pub buttons: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentKind { + Image, + Audio, + Video, + File, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Attachments { + pub inbound: Vec, + pub outbound: Vec, + #[serde(default)] + pub max_size_mb: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MessageLengthLimit { + pub max_chars: u32, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DmSupport { + pub supported: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupModel { + pub kinds: Vec, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StableId { + Yes, + No, + ConsentGated, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GroupSenderIdentity { + pub stable_id: StableId, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SendModelKind { + AnyTime, + ReplyOnly, + PushOnly, + Hybrid, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SendModel { + pub model: SendModelKind, + #[serde(default)] + pub reply_token_ttl_sec: Option, + #[serde(default)] + pub max_objects_per_send: Option, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuotaModel { + Unlimited, + Metered, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProactivePush { + pub supported: bool, + pub quota_model: QuotaModel, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BotToBot { + pub delivered: bool, + pub note: String, + pub source: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TypingIndicator { + pub supported: bool, + pub note: String, + pub source: String, +} + +// ─── Schema 2 — openab-feature-support ────────────────────────────────────── + +#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Implemented, + Partial, + Workaround, + NotImplemented, + #[serde(rename = "n_a")] + Na, +} + +impl Status { + /// A claimed-present feature must cite where it lives. + pub fn requires_source(self) -> bool { + matches!(self, Status::Implemented | Status::Partial | Status::Workaround) } - out } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Feature { + pub feature: String, + pub status: Status, + pub note: String, + #[serde(default)] + pub source: Vec, + #[serde(default)] + pub pr: Option, +} + +// ─── Schema 3 — platform-quirks ───────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuirkKind { + Intrinsic, + OpenabDecision, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Quirk { + pub date: String, + pub title: String, + pub note: String, + pub kind: QuirkKind, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub refs: Vec, +} + +// ─── source-ref parsing ───────────────────────────────────────────────────── + /// A parsed code-ref source: `"path/to/file.rs"` or `"path/to/file.rs#symbol"`. +#[derive(Debug)] pub struct CodeRef<'a> { pub file: &'a str, pub symbol: Option<&'a str>, } +/// Split a source string into file + optional `#symbol`. pub fn parse_code_ref(s: &str) -> CodeRef<'_> { match s.split_once('#') { Some((file, symbol)) => CodeRef { file, symbol: Some(symbol) }, @@ -366,80 +410,11 @@ pub fn parse_code_ref(s: &str) -> CodeRef<'_> { } } -/// Does this source look like an in-repo code ref (vs an official-doc URL)? +/// Does this source string look like an in-repo code ref (vs an official-doc URL)? pub fn is_code_ref(s: &str) -> bool { !s.starts_with("http://") && !s.starts_with("https://") } -// ─── small typed helpers ──────────────────────────────────────────────────── - -fn check_field(t: &Table, field: &str, kind: &Kind, ctx: &str, e: &mut Vec) { - match kind { - Kind::Bool => { - if t.get(field).and_then(Item::as_bool).is_none() { - e.push(format!("{ctx}: `{field}` must be a bool")); - } - } - Kind::Uint => match t.get(field).and_then(Item::as_integer) { - Some(n) if n >= 0 => {} - _ => e.push(format!("{ctx}: `{field}` must be a non-negative integer")), - }, - Kind::OptUint => { - if let Some(item) = t.get(field) { - if !item.is_none() { - match item.as_integer() { - Some(n) if n >= 0 => {} - _ => e.push(format!("{ctx}: `{field}` must be a non-negative integer")), - } - } - } - } - Kind::StrArray => { - if str_array(t, field).is_none() { - e.push(format!("{ctx}: `{field}` must be an array of strings")); - } - } - Kind::Enum(allowed) => match t.get(field).and_then(Item::as_str) { - Some(v) if allowed.contains(&v) => {} - Some(v) => e.push(format!("{ctx}: `{field}` = {v:?} not in {allowed:?}")), - None => e.push(format!("{ctx}: `{field}` must be one of {allowed:?}")), - }, - Kind::EnumArray(allowed) => match str_array(t, field) { - None => e.push(format!("{ctx}: `{field}` must be an array of strings")), - Some(list) => { - for v in list { - if !allowed.contains(&v.as_str()) { - e.push(format!("{ctx}: `{field}` element {v:?} not in {allowed:?}")); - } - } - } - }, - } -} - -fn req_str(t: &Table, key: &str, ctx: &str, e: &mut Vec) { - if t.get(key).and_then(Item::as_str).is_none() { - e.push(format!("{ctx}: `{key}` must be a string")); - } -} - -fn str_array(t: &Table, key: &str) -> Option> { - let arr = t.get(key)?.as_array()?; - let mut out = Vec::new(); - for v in arr.iter() { - out.push(v.as_str()?.to_string()); - } - Some(out) -} - -fn check_unknown_keys(t: &Table, allowed: &[&str], ctx: &str, e: &mut Vec) { - for (k, _) in t.iter() { - if !allowed.contains(&k) { - e.push(format!("{ctx}: unknown key `{k}`")); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -449,33 +424,26 @@ mod tests { let r = parse_code_ref("crates/a/src/b.rs#foo"); assert_eq!(r.file, "crates/a/src/b.rs"); assert_eq!(r.symbol, Some("foo")); - let r = parse_code_ref("crates/a/src/b.rs"); - assert_eq!(r.symbol, None); + assert_eq!(parse_code_ref("crates/a/src/b.rs").symbol, None); assert!(is_code_ref("crates/a.rs")); assert!(!is_code_ref("https://example.com")); } #[test] - fn validate_flags_a_broken_file() { - // An empty doc should trip many required-field errors — proves the - // checker isn't vacuously passing. - let doc: DocumentMut = "schema_version = \"1999-01-01\"".parse().unwrap(); - let errs = validate(&doc, "line"); - assert!(!errs.is_empty()); - assert!(errs.iter().any(|e| e.contains("stale")), "should flag stale version"); - assert!(errs.iter().any(|e| e.contains("[platform]")), "should flag missing platform"); - assert!(errs.iter().any(|e| e.contains("openab_features")), "should flag missing features"); + fn rejects_out_of_set_enum() { + let toml = "kind = \"carrier_pigeon\"\nnote = \"x\"\nsource = \"y\""; + assert!(toml::from_str::(toml).is_err()); + } + + #[test] + fn rejects_unknown_field() { + let toml = "supported = true\nnote = \"x\"\nsource = \"y\"\nbogus = 1"; + assert!(toml::from_str::(toml).is_err()); } #[test] - fn validate_flags_a_bad_enum() { - let doc: DocumentMut = "[capability.transport]\nkind = \"carrier_pigeon\"\nnote = \"x\"\nsource = \"y\"" - .parse() - .unwrap(); - let errs = validate(&doc, "line"); - assert!( - errs.iter().any(|e| e.contains("carrier_pigeon")), - "should reject an out-of-set enum value" - ); + fn status_source_requirement() { + assert!(Status::Implemented.requires_source()); + assert!(!Status::Na.requires_source()); } } diff --git a/crates/platform-schema/tests/conformance.rs b/crates/platform-schema/tests/conformance.rs index 160ac4526..80b33d293 100644 --- a/crates/platform-schema/tests/conformance.rs +++ b/crates/platform-schema/tests/conformance.rs @@ -1,14 +1,14 @@ //! Conformance tests for `docs/platforms/schema/*.toml`. //! -//! Run against the real repo tree: every platform file is parsed + validated -//! against the schema, and every code-ref `source` is checked to still point at -//! a file (and symbol) that exists — so the docs can't silently drift. +//! Run against the real repo tree: every platform file is deserialized into the +//! authoritative structs (that IS the structural/enum check), the closed feature +//! set + schema version are enforced, and every code-ref `source` is checked to +//! still point at a file (and symbol) that exists — so the docs can't drift. use platform_schema::*; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -use toml_edit::DocumentMut; /// The 8 platforms that must have a schema file. const EXPECTED_PLATFORMS: &[&str] = &[ @@ -28,8 +28,9 @@ fn schema_dir() -> PathBuf { repo_root().join("docs/platforms/schema") } -/// Parse every schema/*.toml. A syntax error panics here (that's the parse check). -fn load_all() -> Vec<(String, DocumentMut)> { +/// Deserialize every schema/*.toml. A parse/enum/missing-field/unknown-field +/// error panics here — that IS the structural-validity check. +fn load_all() -> Vec<(String, Platform)> { let dir = schema_dir(); let mut out = Vec::new(); let entries = @@ -41,10 +42,9 @@ fn load_all() -> Vec<(String, DocumentMut)> { } let name = path.file_stem().unwrap().to_string_lossy().into_owned(); let text = fs::read_to_string(&path).unwrap(); - let doc: DocumentMut = text - .parse() - .unwrap_or_else(|e| panic!("{} is not valid TOML: {e}", path.display())); - out.push((name, doc)); + let platform: Platform = toml::from_str(&text) + .unwrap_or_else(|e| panic!("{} failed to deserialize: {e}", path.display())); + out.push((name, platform)); } out } @@ -60,18 +60,66 @@ fn all_expected_platform_files_present() { } #[test] -fn every_file_conforms_to_schema() { - let mut all_errors = Vec::new(); - for (name, doc) in load_all() { - for err in validate(&doc, &name) { - all_errors.push(format!("{name}.toml: {err}")); +fn schema_version_is_current() { + for (name, p) in load_all() { + assert_eq!( + p.schema_version, SCHEMA_VERSION, + "{name}.toml: schema_version {} != current {SCHEMA_VERSION} (stale page)", + p.schema_version + ); + } +} + +#[test] +fn platform_name_matches_filename() { + for (name, p) in load_all() { + assert_eq!( + p.platform.name, name, + "{name}.toml: [platform].name is {:?}, must match the filename", + p.platform.name + ); + } +} + +#[test] +fn feature_set_is_exactly_the_closed_set() { + let want: BTreeSet<&str> = EXPECTED_FEATURES.iter().copied().collect(); + for (name, p) in load_all() { + let mut seen: BTreeSet = BTreeSet::new(); + for f in &p.openab_features { + assert!( + want.contains(f.feature.as_str()), + "{name}.toml: unknown feature key {:?}", + f.feature + ); + assert!( + seen.insert(f.feature.clone()), + "{name}.toml: duplicate feature {:?}", + f.feature + ); + } + let got: BTreeSet<&str> = seen.iter().map(String::as_str).collect(); + assert_eq!( + got, want, + "{name}.toml: feature set mismatch; missing {:?}", + want.difference(&got).collect::>() + ); + } +} + +#[test] +fn present_features_cite_a_source() { + for (name, p) in load_all() { + for f in &p.openab_features { + if f.status.requires_source() { + assert!( + !f.source.is_empty(), + "{name}.toml: feature {:?} claims presence but cites no source", + f.feature + ); + } } } - assert!( - all_errors.is_empty(), - "schema violations:\n {}", - all_errors.join("\n ") - ); } /// The core anti-drift check: every feature code-ref points at a real file, and @@ -80,14 +128,19 @@ fn every_file_conforms_to_schema() { fn feature_sources_exist_in_tree() { let root = repo_root(); let mut errs = Vec::new(); - for (name, doc) in load_all() { - for (ctx, src) in feature_code_refs(&doc) { - if !is_code_ref(&src) { - errs.push(format!("{name}.toml {ctx}: source {src:?} is a URL, expected a file ref")); - continue; - } - if let Err(msg) = check_code_ref(&root, &src) { - errs.push(format!("{name}.toml {ctx}: {msg}")); + for (name, p) in load_all() { + for f in &p.openab_features { + for src in &f.source { + if !is_code_ref(src) { + errs.push(format!( + "{name}.toml feature {:?}: source {src:?} is a URL, expected a file ref", + f.feature + )); + continue; + } + if let Err(msg) = check_code_ref(&root, src) { + errs.push(format!("{name}.toml feature {:?}: {msg}", f.feature)); + } } } } @@ -98,10 +151,14 @@ fn feature_sources_exist_in_tree() { fn quirk_code_sources_exist_in_tree() { let root = repo_root(); let mut errs = Vec::new(); - for (name, doc) in load_all() { - for (ctx, src) in quirk_code_refs(&doc) { - if let Err(msg) = check_code_ref(&root, &src) { - errs.push(format!("{name}.toml {ctx}: {msg}")); + for (name, p) in load_all() { + for q in &p.quirks { + if let Some(src) = &q.source { + if is_code_ref(src) { + if let Err(msg) = check_code_ref(&root, src) { + errs.push(format!("{name}.toml quirk {:?}: {msg}", q.title)); + } + } } } } @@ -124,7 +181,7 @@ fn check_code_ref(root: &Path, src: &str) -> Result<(), String> { } /// The template must keep enumerating every capability section + feature key, so -/// a schema change can't silently leave the human-facing template behind. +/// a struct change can't silently leave the human-facing template behind. #[test] fn template_enumerates_every_section_and_feature() { let text = fs::read_to_string(repo_root().join("docs/platforms/_template.toml")) diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index a06d3b953..ab660aafb 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -6,7 +6,7 @@ # snake_case) and fill every field. # # This template IS the schema documentation. The authoritative *types* live in -# the Rust structs at `crates/.../platform_schema.rs` (serde). Conformance +# the Rust structs at `crates/platform-schema/src/lib.rs` (serde). Conformance # fully deserializes every real schema/*.toml into those structs, and checks # this template still enumerates every capability section + every feature key — # so if the structs gain/rename a field, this template must be updated too. From 510541c54fa805efa5558e75b3ed6c13fd16194f Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Wed, 8 Jul 2026 09:39:50 +0000 Subject: [PATCH 29/37] =?UTF-8?q?docs(platforms):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20version,=20EOF=20newlines,=20PR=20refs,=20sourcing?= =?UTF-8?q?=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the CHANGES REQUESTED findings on #1295: - F1: bump schema version 2026-07-04 → 2026-07-07 across README (current version + conformance table) and every page's `**Schema version:**` line, so it matches the TOML schema. Findings-log dates are left as-is (real dates, not the schema version). - F2: add trailing newline to the platform pages that were missing one. - F4: replace placeholder `[PR #TBD]` / `[PR: @TBD]` with `#1295`. - F3: add a "Machine-readable schema" note to the README — the `schema/*.toml` files (with `file#symbol` refs) are the machine-checked source of truth; the `.md` `file:line` refs are a point-in-time snapshot for readability. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/README.md | 28 ++++++++++++++++++---------- docs/platforms/_template.md | 2 +- docs/platforms/discord.md | 4 ++-- docs/platforms/feishu.md | 4 ++-- docs/platforms/googlechat.md | 4 ++-- docs/platforms/line.md | 4 ++-- docs/platforms/slack.md | 4 ++-- docs/platforms/teams.md | 4 ++-- docs/platforms/telegram.md | 4 ++-- docs/platforms/wecom.md | 6 +++--- 10 files changed, 36 insertions(+), 28 deletions(-) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index 4ff0989c3..a50489be5 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -14,12 +14,20 @@ Each platform page has three schema-driven sections: **Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic facts link the **official platform doc**; OpenAB decisions/findings link the **PR/issue**. +## Machine-readable schema + +Alongside these Markdown pages, each platform has a machine-readable `schema/.toml`, validated by the `crates/platform-schema` conformance tests (run in CI on any schema change). The TOML files are the **machine-checked source of truth**; the `.md` pages are the **human-readable presentation layer** — neither blocks the other's evolution. + +- In `schema/*.toml`, OpenAB code references use `file.rs#symbol` (a grep-stable symbol name, **no line numbers**), so conformance can confirm they still exist without breaking on unrelated edits above the target. +- The `.md` Section 2 tables use `file:line` as a **point-in-time snapshot** for readability; treat the TOML `#symbol` refs as the durable, machine-checked form. +- See [`_template.toml`](./_template.toml) for the full schema definition + per-field docs. + ## Schema version The three schemas share **one date-based version**. When any schema changes, bump the date here and re-verify the pages. -- **Current schema version: `2026-07-04`** -- Each platform page records the version it was written against as a single line near the top: `**Schema version:** 2026-07-04`. +- **Current schema version: `2026-07-07`** +- Each platform page records the version it was written against as a single line near the top: `**Schema version:** 2026-07-07`. - No per-page front-matter: the platform is the **filename**, page **ownership is tracked separately** (not in these pages), and last-touched is in **git history**. The conformance table below lists each page's version — a page older than the current version is stale and needs an update pass. @@ -28,14 +36,14 @@ The conformance table below lists each page's version — a page older than the | Platform | Schema version | |---|---| -| [line](./line.md) | 2026-07-04 | -| [slack](./slack.md) | 2026-07-04 | -| [telegram](./telegram.md) | 2026-07-04 | -| [discord](./discord.md) | 2026-07-04 | -| [feishu](./feishu.md) | 2026-07-04 | -| [wecom](./wecom.md) | 2026-07-04 | -| [googlechat](./googlechat.md) | 2026-07-04 | -| [teams](./teams.md) | 2026-07-04 | +| [line](./line.md) | 2026-07-07 | +| [slack](./slack.md) | 2026-07-07 | +| [telegram](./telegram.md) | 2026-07-07 | +| [discord](./discord.md) | 2026-07-07 | +| [feishu](./feishu.md) | 2026-07-07 | +| [wecom](./wecom.md) | 2026-07-07 | +| [googlechat](./googlechat.md) | 2026-07-07 | +| [teams](./teams.md) | 2026-07-07 | --- diff --git a/docs/platforms/_template.md b/docs/platforms/_template.md index 67168075a..6add66c99 100644 --- a/docs/platforms/_template.md +++ b/docs/platforms/_template.md @@ -1,6 +1,6 @@ # — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the adapter. For operator setup see `docs/.md`. Follows the schemas in [`README.md`](./README.md). diff --git a/docs/platforms/discord.md b/docs/platforms/discord.md index 10c0ecec3..8c6eb38fc 100644 --- a/docs/platforms/discord.md +++ b/docs/platforms/discord.md @@ -1,6 +1,6 @@ # Discord — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the Discord adapter. For operator setup see `docs/discord.md`. Follows the schemas in [`README.md`](./README.md). @@ -77,4 +77,4 @@ DM channels can't hold threads, so DMs reuse the DM channel directly and are tre - 2026-07-04 (A) Message content hard limit is 2000 chars, matching `DiscordAdapter::message_limit()`; the router chunks longer replies. [Channel](https://docs.discord.com/developers/resources/channel) - 2026-07-04 (A) Thread channel types are ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12), API v9+; identified by `thread_metadata`, not `parent_id` (category children also carry `parent_id`); `detect_thread` follows this. [Channel](https://docs.discord.com/developers/resources/channel) - 2026-07-04 (A) Transport is a bot-initiated persistent WebSocket Gateway (WSS) authenticated by Identify(op 2) + bot token + intents — no per-event inbound signature to verify (unlike webhook platforms). [Gateway](https://docs.discord.com/developers/topics/gateway) -- 2026-07-04 (B) Section-2 ref audit: chunking lives at `adapter.rs:686`/`:1105` (not the trait def near `:306`); slash-command registration is `set_global_commands` at `discord.rs:1386`; `is_denied_user` at `discord.rs:2922`, trusted-bot bypass at `discord.rs:2947`. Corrected stale line refs. [PR #TBD] +- 2026-07-04 (B) Section-2 ref audit: chunking lives at `adapter.rs:686`/`:1105` (not the trait def near `:306`); slash-command registration is `set_global_commands` at `discord.rs:1386`; `is_denied_user` at `discord.rs:2922`, trusted-bot bypass at `discord.rs:2947`. Corrected stale line refs. [PR #1295] diff --git a/docs/platforms/feishu.md b/docs/platforms/feishu.md index 8f72a1fc2..7e5f41fd2 100644 --- a/docs/platforms/feishu.md +++ b/docs/platforms/feishu.md @@ -1,6 +1,6 @@ # Feishu / Lark — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the Feishu / Lark adapter. For operator setup see `docs/feishu.md`. Follows the schemas in [`README.md`](./README.md). @@ -87,4 +87,4 @@ Webhook path enforces: 1 MB body cap (`feishu.rs:3192`), per-IP rate limit, SHA2 - 2026-07-04 (B) Adapter relies on the 230072 cap → CardKit streaming-card promotion; card streaming (S6) defaults to `auto`. [crates/openab-gateway/src/adapters/feishu.rs:1550, :2799] - 2026-07-04 (B) `create_topic` (from core `create_thread`) is a deliberate no-op in the Feishu adapter — core falls back to same-channel reply. [crates/openab-gateway/src/adapters/feishu.rs:2578] - 2026-07-04 (B) Reaction emoji support limited to an 8-entry hard-coded map; unmapped emojis are silently dropped. [crates/openab-gateway/src/adapters/feishu.rs:2289] -- 2026-07-04 (B) Slash commands `/reset` and `/cancel` are intercepted in core `gateway.rs` (:1376/:1389), not the adapter; `dispatch.rs` does not hold the literals. [crates/openab-core/src/gateway.rs:1376] \ No newline at end of file +- 2026-07-04 (B) Slash commands `/reset` and `/cancel` are intercepted in core `gateway.rs` (:1376/:1389), not the adapter; `dispatch.rs` does not hold the literals. [crates/openab-core/src/gateway.rs:1376] diff --git a/docs/platforms/googlechat.md b/docs/platforms/googlechat.md index 22f617ebc..73f64c155 100644 --- a/docs/platforms/googlechat.md +++ b/docs/platforms/googlechat.md @@ -1,6 +1,6 @@ # Google Chat — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the Google Chat adapter. For operator setup see `docs/googlechat.md`. Follows the schemas in [`README.md`](./README.md). @@ -80,4 +80,4 @@ Unlike platforms where `delete_message` falls back to the trait's edit-to-zero-w - 2026-07-04 (A) Slash commands are registered in the Cloud console with a **commandId 1–1000** and delivered as a `MESSAGE` event carrying `message.slashCommand` / `message.annotation.slashCommand`. [https://developers.google.com/workspace/chat/commands] - 2026-07-04 (A) In spaces the app is only invoked when @mentioned (mention stripped into `argumentText`); DMs deliver every message — so mention-gating is enforced platform-side, not by OpenAB's `@mention` string check. [https://developers.google.com/workspace/chat/receive-respond-interactions] - 2026-07-04 (A) The `MESSAGE` interaction event is defined as "A user messages a Chat app"; other bots'/apps' messages are not delivered, and there is no typing/composing API for apps. [https://developers.google.com/workspace/chat/receive-respond-interactions] -- 2026-07-04 (A) Inbound webhooks are signed by `chat@system.gserviceaccount.com` (RS256, JWKS at `oauth2/v3/certs`); OpenAB verifies issuer (`accounts.google.com`), audience, exp, and the `email` claim. [https://developers.google.com/workspace/chat/receive-respond-interactions] \ No newline at end of file +- 2026-07-04 (A) Inbound webhooks are signed by `chat@system.gserviceaccount.com` (RS256, JWKS at `oauth2/v3/certs`); OpenAB verifies issuer (`accounts.google.com`), audience, exp, and the `email` claim. [https://developers.google.com/workspace/chat/receive-respond-interactions] diff --git a/docs/platforms/line.md b/docs/platforms/line.md index 8dbb3a2f3..97c0656f8 100644 --- a/docs/platforms/line.md +++ b/docs/platforms/line.md @@ -1,6 +1,6 @@ # LINE — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the LINE adapter. For operator setup see `docs/line.md`. Follows the schemas in [`README.md`](./README.md). @@ -88,4 +88,4 @@ When an image/audio uses `contentProvider.type == "external"`, or the access tok - 2026-07-04 (B) deny-echo on LINE reuses the hybrid `dispatch_line_reply` keyed by the original event token: Reply in practice (fresh token at ingress), but not a hard "never Push" guarantee — an expired token would fall back to Push like any reply. [PR #1291] - 2026-07-04 (B) LINE adapter design: L1 HMAC-SHA256 at ingress, group two-mode admission via `isSelf` mention-gating, "unknown" never allowlistable, `is_bot` always false, early-ack + semaphore-bounded background processing, single text object per reply. [PR #1291] -**Open item (unknowable without running the platform):** the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. \ No newline at end of file +**Open item (unknowable without running the platform):** the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. diff --git a/docs/platforms/slack.md b/docs/platforms/slack.md index 76f6e9dc5..530c3248a 100644 --- a/docs/platforms/slack.md +++ b/docs/platforms/slack.md @@ -1,6 +1,6 @@ # Slack — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the Slack adapter. For operator setup see `docs/slack.md`. Follows the schemas in [`README.md`](./README.md). @@ -76,4 +76,4 @@ Unlike the gateway/Discord paths (which call `AdapterRouter::gate_incoming`), th - 2026-07-04 (A) `chat.postMessage` limits: `text` recommended ≤4,000 / truncated at 40,000; `markdown_text` (and the Block Kit `markdown` block) up to 12,000 — the basis for the 11,900 `message_limit`. [https://docs.slack.dev/reference/methods/chat.postMessage] - 2026-07-04 (A) Other bots' messages ARE delivered as `message` events with `subtype: bot_message` + `bot_id`; OpenAB gates them via `allow_bot_messages` and `trusted_bot_ids`. [https://docs.slack.dev/reference/events/message/bot_message/] - 2026-07-04 (A) Proactive posting rate = no more than 1 message/second/channel (bursts tolerated; `chat.postMessage` is a Special Tier method); relevant to multibot loop caps. [https://docs.slack.dev/apis/web-api/rate-limits/] -- 2026-07-04 (B) `chat.stopStream` appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-`chat.update`. [openabdev/openab#1055] \ No newline at end of file +- 2026-07-04 (B) `chat.stopStream` appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-`chat.update`. [openabdev/openab#1055] diff --git a/docs/platforms/teams.md b/docs/platforms/teams.md index 0a4f6b3e5..2ca2eb59b 100644 --- a/docs/platforms/teams.md +++ b/docs/platforms/teams.md @@ -1,6 +1,6 @@ # MS Teams — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the MS Teams adapter. For operator setup see `docs/teams.md`. Follows the schemas in [`README.md`](./README.md). @@ -79,4 +79,4 @@ JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the - 2026-07-04 (A) No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed. [command menu](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) - 2026-07-04 (A) 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. [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) - 2026-07-04 (B) Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter (teams.rs:504-508) forwards `from.id` as trust-gate key. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) -- 2026-07-04 (B) 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. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) \ No newline at end of file +- 2026-07-04 (B) 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. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) diff --git a/docs/platforms/telegram.md b/docs/platforms/telegram.md index a4010aa58..9771b21a5 100644 --- a/docs/platforms/telegram.md +++ b/docs/platforms/telegram.md @@ -1,6 +1,6 @@ # Telegram — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the Telegram adapter. For operator setup see `docs/telegram.md`. Follows the schemas in [`README.md`](./README.md). @@ -81,4 +81,4 @@ Only `secret_token` + an optional source-subnet check gate the webhook. Subnet e - 2026-07-04 (B) `delete_message` is unimplemented in the Telegram adapter despite native support — no `deleteMessage` call or command handler; trait default returns unsupported. [`crates/openab-gateway/src/adapters/telegram.rs:405`; `crates/openab-core/src/adapter.rs:353`] - 2026-07-04 (B) Outbound send sets `message_thread_id` for topic routing but never `reply_to_message_id`, so agent quote-replies aren't native quotes. [`crates/openab-gateway/src/adapters/telegram.rs:610`] - 2026-07-04 (B) MAX_DOWNLOAD constants (IMAGE 10 / FILE 20 / AUDIO 20 MB) live in the gateway crate, not core; core adds a separate 25 MB Whisper STT cap. [`crates/openab-gateway/src/media.rs:13-15`; `crates/openab-core/src/media.rs:311`] -- 2026-07-04 (B) Voice/audio STT is not done in the adapter; core `gateway.rs:955`/`:1340` transcribe `audio` attachments when `stt_config.enabled`. [`crates/openab-core/src/gateway.rs:955`, `:1340`] \ No newline at end of file +- 2026-07-04 (B) Voice/audio STT is not done in the adapter; core `gateway.rs:955`/`:1340` transcribe `audio` attachments when `stt_config.enabled`. [`crates/openab-core/src/gateway.rs:955`, `:1340`] diff --git a/docs/platforms/wecom.md b/docs/platforms/wecom.md index eee86a8fa..8363c552c 100644 --- a/docs/platforms/wecom.md +++ b/docs/platforms/wecom.md @@ -1,6 +1,6 @@ # WeCom (企業微信) — platform notes -**Schema version:** 2026-07-04 +**Schema version:** 2026-07-07 Engineering-facing capability & quirks reference for the WeCom adapter. For operator setup see `docs/wecom.md`. Follows the schemas in [`README.md`](./README.md). @@ -78,6 +78,6 @@ Only `text`, `image`, `file` msgtypes are forwarded (`wecom.rs:1022`); `voice` / - 2026-07-04 (A) No edit API; only recall (`/cgi-bin/message/recall`) within 24h on this app's own messages (delete, not edit; WeChat-plugin-end data can't be pulled back). [https://developer.work.weixin.qq.com/document/path/94867] - 2026-07-04 (A) Media caps: image 10MB (JPG/PNG), voice 2MB/60s (AMR), video 10MB (MP4), file 20MB; min 5 bytes; temp media_id valid 3 days. [https://developer.work.weixin.qq.com/document/path/90253] - 2026-07-04 (A) Callback auth = SHA1 msg_signature (sorted token/ts/nonce/encrypt) + AES-256-CBC via 43-char EncodingAESKey, PKCS7 block_size=32, IV = first 16 key bytes. [https://developer.work.weixin.qq.com/document/path/90238] -- 2026-07-04 (B) All section-2 `file:line` refs verified against the tree at `/tmp/openab-check`; corrected `send_text` / `fetch_media_with_retry` / `gate_incoming` line numbers and the mention-gating range. `WecomAdapter` is a standalone handler (does **not** impl `ChatAdapter`); trait defaults referenced are in `adapter.rs`. [PR: @TBD] +- 2026-07-04 (B) All section-2 `file:line` refs verified against the tree at `/tmp/openab-check`; corrected `send_text` / `fetch_media_with_retry` / `gate_incoming` line numbers and the mention-gating range. `WecomAdapter` is a standalone handler (does **not** impl `ChatAdapter`); trait defaults referenced are in `adapter.rs`. [PR #1295] -*Sourcing: `(A)` intrinsic facts → official WeCom doc; `(B)` OpenAB decisions/findings → `file:line` (PR link `@TBD`).* +*Sourcing: `(A)` intrinsic facts → official WeCom doc; `(B)` OpenAB decisions/findings → `file:line` (PR link `#1295`).* From b1b3fe4578ce7cef46ec1906f04da345f836a25b Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Wed, 8 Jul 2026 09:45:08 +0000 Subject: [PATCH 30/37] =?UTF-8?q?docs(platforms):=20drop=20the=20.md=20pag?= =?UTF-8?q?es=20=E2=80=94=20schema/*.toml=20is=20the=20single=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-platform Markdown pages duplicated the schema/*.toml content (their prose now lives in the toml `note` fields, their findings logs in `[[quirks]]`). Remove them and _template.md; the TOML files + conformance crate are the sole source of truth going forward. - Delete docs/platforms/{line,slack,telegram,discord,feishu,wecom,googlechat, teams}.md and _template.md. - Rewrite README as a toml-only index + schema reference (capability sections, the 16-feature set, quirks), pointing at schema/*.toml and _template.toml. - Fix stale "converted from .md" provenance comments in the toml headers now that those md files are gone. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/platforms/README.md | 119 +++++++++++++--------------- docs/platforms/_template.md | 57 ------------- docs/platforms/discord.md | 80 ------------------- docs/platforms/feishu.md | 90 --------------------- docs/platforms/googlechat.md | 83 ------------------- docs/platforms/line.md | 91 --------------------- docs/platforms/schema/discord.toml | 2 +- docs/platforms/schema/feishu.toml | 2 +- docs/platforms/schema/line.toml | 2 +- docs/platforms/schema/slack.toml | 2 +- docs/platforms/schema/teams.toml | 2 +- docs/platforms/schema/telegram.toml | 2 +- docs/platforms/slack.md | 79 ------------------ docs/platforms/teams.md | 82 ------------------- docs/platforms/telegram.md | 84 -------------------- docs/platforms/wecom.md | 83 ------------------- 16 files changed, 62 insertions(+), 798 deletions(-) delete mode 100644 docs/platforms/_template.md delete mode 100644 docs/platforms/discord.md delete mode 100644 docs/platforms/feishu.md delete mode 100644 docs/platforms/googlechat.md delete mode 100644 docs/platforms/line.md delete mode 100644 docs/platforms/slack.md delete mode 100644 docs/platforms/teams.md delete mode 100644 docs/platforms/telegram.md delete mode 100644 docs/platforms/wecom.md diff --git a/docs/platforms/README.md b/docs/platforms/README.md index a50489be5..f79332748 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -1,94 +1,89 @@ -# Messaging platforms — schemas & index +# Messaging platforms — schema & index Engineering/reviewer-facing knowledge base for how each messaging platform behaves and how OpenAB maps it. **Distinct from** the operator setup guides in `docs/.md`. -This README is **not a giant table** — it defines the **schemas** that every per-platform page follows. One page per platform (`line.md`, `slack.md`, …), each filled against the schemas below. See [`_template.md`](./_template.md) for a blank page. +This directory is **not a giant table** — it defines a **schema** that every platform fills in its own `schema/.toml`. The files are the machine-checked source of truth, validated by the `crates/platform-schema` conformance tests (run in CI). See [`_template.toml`](./_template.toml) for the blank schema + per-field docs. ## How it works -Each platform page has three schema-driven sections: +Each `schema/.toml` has three schema-driven parts: -1. **Platform capability** (fixed fields) — the platform's intrinsic nature and what a bot can/can't do inside it. Same fields for every platform. Source of truth = **official docs**. -2. **OpenAB feature support** (fixed fields) — for each OpenAB capability, whether this platform implements it, and how. Same fields for every platform. Source of truth = **our code (`file:line`) + the PR that decided it**. -3. **Platform quirks** (flexible) — anything that doesn't fit a fixed field (e.g. LINE's reply/push model). Free-form, plus a dated findings log. +1. **Platform capability** (`[capability.*]`, fixed fields) — the platform's intrinsic nature and what a bot can/can't do inside it. Same fields for every platform. Source of truth = **official docs** (each field carries a `source` URL). +2. **OpenAB feature support** (`[[openab_features]]`, the closed 16-feature set) — for each OpenAB capability, a `status` + note + code `source`. Source of truth = **our code + the PR that decided it**. +3. **Platform quirks** (`[[quirks]]`, freeform dated log) — anything that doesn't fit a fixed field (e.g. LINE's reply/push model), plus a findings log. -**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic facts link the **official platform doc**; OpenAB decisions/findings link the **PR/issue**. +**Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic `(A)` facts link the **official platform doc** (a `source` URL); OpenAB `(B)` decisions/findings point at the **code** (`file.rs#symbol`) and, where relevant, the **PR** (`pr` / `refs`). Code refs use a grep-stable `#symbol` (no line numbers), so conformance can confirm they still exist without breaking on unrelated edits above the target. -## Machine-readable schema +## Conformance -Alongside these Markdown pages, each platform has a machine-readable `schema/.toml`, validated by the `crates/platform-schema` conformance tests (run in CI on any schema change). The TOML files are the **machine-checked source of truth**; the `.md` pages are the **human-readable presentation layer** — neither blocks the other's evolution. +`crates/platform-schema` deserializes every `schema/*.toml` into typed structs and, in CI, enforces: -- In `schema/*.toml`, OpenAB code references use `file.rs#symbol` (a grep-stable symbol name, **no line numbers**), so conformance can confirm they still exist without breaking on unrelated edits above the target. -- The `.md` Section 2 tables use `file:line` as a **point-in-time snapshot** for readability; treat the TOML `#symbol` refs as the durable, machine-checked form. -- See [`_template.toml`](./_template.toml) for the full schema definition + per-field docs. +- **structural validity** — required fields, closed enum sets, the exact 16-feature set, unknown-key rejection; +- **version currency** — every file's `schema_version` matches the current one (a stale file fails the build); +- **anti-drift** — every `source` code-ref still resolves to a real file + `#symbol` in the tree. -## Schema version +- **Current schema version: `2026-07-07`** — the top-line `schema_version` in each file. Bump it when the schema changes; the conformance test then flags every file that hasn't been re-verified. -The three schemas share **one date-based version**. When any schema changes, bump the date here and re-verify the pages. +## Platforms -- **Current schema version: `2026-07-07`** -- Each platform page records the version it was written against as a single line near the top: `**Schema version:** 2026-07-07`. -- No per-page front-matter: the platform is the **filename**, page **ownership is tracked separately** (not in these pages), and last-touched is in **git history**. - -The conformance table below lists each page's version — a page older than the current version is stale and needs an update pass. - -### Conformance - -| Platform | Schema version | +| Platform | Schema file | |---|---| -| [line](./line.md) | 2026-07-07 | -| [slack](./slack.md) | 2026-07-07 | -| [telegram](./telegram.md) | 2026-07-07 | -| [discord](./discord.md) | 2026-07-07 | -| [feishu](./feishu.md) | 2026-07-07 | -| [wecom](./wecom.md) | 2026-07-07 | -| [googlechat](./googlechat.md) | 2026-07-07 | -| [teams](./teams.md) | 2026-07-07 | +| line | [schema/line.toml](./schema/line.toml) | +| slack | [schema/slack.toml](./schema/slack.toml) | +| telegram | [schema/telegram.toml](./schema/telegram.toml) | +| discord | [schema/discord.toml](./schema/discord.toml) | +| feishu | [schema/feishu.toml](./schema/feishu.toml) | +| wecom | [schema/wecom.toml](./schema/wecom.toml) | +| googlechat | [schema/googlechat.toml](./schema/googlechat.toml) | +| teams | [schema/teams.toml](./schema/teams.toml) | --- -## Schema 1 — `platform-capability` +## Schema reference -Fixed fields. Every platform fills all of them. Each value carries an official-doc link where the fact isn't self-evident. Use `?` only when genuinely unverified (note what's missing). +The authoritative field list + types live in [`_template.toml`](./_template.toml) and the structs in `crates/platform-schema/src/lib.rs`. Summary below. -| Field | Meaning / allowed values | +### 1 — `[capability.*]` (platform-capability) + +Fixed fields, same for every platform; each carries a typed value + `note` + official-doc `source`. Use `?` in a note only when a fact is genuinely unverified. + +| Section | Meaning / allowed values | |---|---| -| `transport` | how events arrive: webhook / websocket / socket-mode / long-poll | -| `inbound_auth` | L1 request-auth / signature scheme (e.g. HMAC-SHA256, JWT RS256, AES) | -| `threads` | native / reply-to-only / emulated / none — plus the model | +| `transport` | how events arrive: `webhook` / `websocket` / `socket_mode` / `long_poll` | +| `inbound_auth` | L1 request-auth / signature scheme: `hmac_sha256` / `jwt_rs256` / `aes` / `shared_secret` / `oauth` / `none` | +| `threads` | `native` / `reply_to_only` / `emulated` / `none` | | `slash_commands` | supported? how registered / delivered? | -| `mentions` | how the bot detects being addressed (@mention, username, isSelf flag…) | +| `mentions` | how the bot detects being addressed: `at_mention` / `username` / `self_flag` / `none` | | `emoji_reactions` | can a bot **add** / **remove** reactions? does it **receive** reaction events? | | `edit_message` | can a bot edit its own already-sent message? | -| `delete_message` | can a bot delete a message? (own / others) | -| `rich_content` | cards / buttons / markdown / rich-text support | -| `attachments` | inbound & outbound media types + size limits | +| `delete_message` | can a bot delete a message? scope: `none` / `own` / `others` / `own_and_others` | +| `rich_content` | markdown / cards / buttons support | +| `attachments` | inbound & outbound media types (`image`/`audio`/`video`/`file`) + size cap | | `message_length_limit` | max chars per outbound message (chunking implication) | | `dm_support` | 1:1 direct messages supported? | | `group_model` | group / channel / room / space taxonomy | -| `group_sender_identity` | is a stable per-user sender id available in group events? consent-gated? | -| `send_model` | reply vs push; any reply window / token TTL | -| `proactive_push` | can the bot message unsolicited? quota / rate limits | +| `group_sender_identity` | stable per-user sender id in group events: `yes` / `no` / `consent_gated` | +| `send_model` | `any_time` / `reply_only` / `push_only` / `hybrid`; reply-token TTL; batch cap | +| `proactive_push` | can the bot message unsolicited? quota model: `unlimited` / `metered` / `none` | | `bot_to_bot` | does the platform deliver other bots' messages to this bot? | | `typing_indicator` | supported? | -## Schema 2 — `openab-feature-support` +### 2 — `[[openab_features]]` (openab-feature-support) -Fixed fields = the OpenAB capabilities exercised across adapters (derived from the `ChatAdapter` trait in `crates/openab-core/src/adapter.rs` + the trust/ingress layer). For each, give a **status** + note + `file:line` + PR ref. +The closed set of OpenAB capabilities (derived from the `ChatAdapter` trait in `crates/openab-core/src/adapter.rs` + the trust/ingress layer). Each block: `feature` + `status` + `note` + `source` (array of `file.rs#symbol`) + optional `pr`. -**Status enum:** `implemented` · `partial` · `workaround` · `not-implemented` · `n/a` (platform can't support it). -Always explain `workaround` / `partial` / `limited` — that "why" is the valuable part. +**Status enum:** `implemented` · `partial` · `workaround` · `not_implemented` · `n_a` (platform can't support it). Always explain `workaround` / `partial` — that "why" is the valuable part. -| Feature | Notes to capture | +| Feature key | Covers | |---|---| | `send_message` | basic outbound | -| `message_split/chunking` | long-message handling (`split_delivery`) | +| `message_split` | long-message handling (`split_delivery`) | | `streaming` | `stream_begin` / `stream_append` / `stream_finish` — live vs batched | -| `reply/quote` | `send_message_with_reply` | -| `edit_message` | own-message edit (`edit_message`) | -| `delete_message` | `delete_message` | +| `reply_quote` | `send_message_with_reply` | +| `edit_message` | own-message edit | +| `delete_message` | delete own / others | | `emoji_reactions` | `add_reaction` / `remove_reaction` | -| `threads/topics` | `create_thread` / `create_topic` | +| `threads_topics` | `create_thread` / `create_topic` | | `media_inbound` | images / files / audio ingestion | | `voice_stt` | speech-to-text on voice notes | | `trust_gate` | allowlist / identity-trust enforcement point | @@ -96,15 +91,13 @@ Always explain `workaround` / `partial` / `limited` — that "why" is the valuab | `mention_gating` | require @mention in groups | | `slash_commands` | `/reset`, `/cancel` handling | | `multibot` | multiple bots in one channel | -| `group_routing` | group/session routing | - -## Schema 3 — `platform-quirks` +| `group_routing` | group / session routing | -Flexible. Anything not captured by Schema 1/2 — special models, gotchas, structural constraints. Two parts: +### 3 — `[[quirks]]` (platform-quirks) -- **Quirks** — free-form subsections (e.g. "Reply/Push model"). No fixed fields; whatever the platform needs. -- **Findings log** — dated entries, newest first, one line each. Tag `(A)` intrinsic → official-doc link; `(B)` OpenAB decision/finding → PR/issue link. +Freeform dated log — anything not captured by sections 1/2 (special models, gotchas, structural constraints) plus a findings trail. Each block: -``` -- YYYY-MM-DD (A|B) . [source link] -``` +- `date` (`YYYY-MM-DD`), `title`, `note` (prose) — required. +- `kind` (required): `intrinsic` (a platform fact) or `openab_decision` (a choice/finding of ours). +- `source` (optional): official-doc URL, or `file.rs` / `file.rs#symbol`. +- `refs` (optional): PR/ADR links, e.g. `["#1291"]`. diff --git a/docs/platforms/_template.md b/docs/platforms/_template.md deleted file mode 100644 index 6add66c99..000000000 --- a/docs/platforms/_template.md +++ /dev/null @@ -1,57 +0,0 @@ -# — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the adapter. For operator setup see `docs/.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | | | -| inbound_auth | | | -| threads | | | -| slash_commands | | | -| mentions | | | -| emoji_reactions | | | -| edit_message | | | -| delete_message | | | -| rich_content | | | -| attachments | | | -| message_length_limit | | | -| dm_support | | | -| group_model | | | -| group_sender_identity | | | -| send_model | | | -| proactive_push | | | -| bot_to_bot | | | -| typing_indicator | | | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | | | | -| message_split/chunking | | | | -| streaming | | | | -| reply/quote | | | | -| edit_message | | | | -| delete_message | | | | -| emoji_reactions | | | | -| threads/topics | | | | -| media_inbound | | | | -| voice_stt | | | | -| trust_gate | | | | -| deny_echo | | | | -| mention_gating | | | | -| slash_commands | | | | -| multibot | | | | -| group_routing | | | | - -## 3. Platform quirks (`platform-quirks`) - -### - - -### Findings log -- YYYY-MM-DD (A|B) . [source] diff --git a/docs/platforms/discord.md b/docs/platforms/discord.md deleted file mode 100644 index 8c6eb38fc..000000000 --- a/docs/platforms/discord.md +++ /dev/null @@ -1,80 +0,0 @@ -# Discord — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the Discord adapter. For operator setup see `docs/discord.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | WebSocket Gateway (persistent WSS `wss://gateway.discord.gg/`). The bot opens a persistent gateway connection and receives events; outbound actions use the REST HTTP API. | [Gateway](https://docs.discord.com/developers/topics/gateway) | -| inbound_auth | Gateway handshake via Identify (opcode 2) carrying the bot token + intents. REST calls use the `Authorization: Bot ` header. Gateway is a bot-initiated persistent socket, so there is no per-event inbound signature to verify (unlike webhook platforms). | [Gateway](https://docs.discord.com/developers/topics/gateway) · [Reference](https://docs.discord.com/developers/reference) | -| threads | native. ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12) — temporary sub-channels of a text/forum/announcement channel (types available in API v9+). Threads are themselves channels (own `channel_id`, `thread_metadata`, `parent_id`). | [Channel](https://docs.discord.com/developers/resources/channel) | -| slash_commands | supported. Application commands of type CHAT_INPUT (1), registered over HTTP: global `POST /applications/{id}/commands` or per-guild `POST /applications/{id}/guilds/{gid}/commands`. Invocations delivered as `INTERACTION_CREATE` with an interaction token for the response. | [Application Commands](https://docs.discord.com/developers/interactions/application-commands) | -| mentions | Bot detects being addressed via user mention `<@user_id>` (and legacy nick form `<@!id>`) or role mention `<@&role_id>` in `mention_roles`. Gateway also flags `mentions` / `mention_everyone`. | [Message](https://docs.discord.com/developers/resources/message) | -| emoji_reactions | Bot **add**: `PUT` Create Reaction; **remove**: Delete Own Reaction (own) or delete others' with MANAGE_MESSAGES. **Receives**: `MESSAGE_REACTION_ADD` / `MESSAGE_REACTION_REMOVE` (needs GUILD_MESSAGE_REACTIONS intent `1 << 10`; DIRECT_MESSAGE_REACTIONS for DMs). | [Message](https://docs.discord.com/developers/resources/message) · [Gateway](https://docs.discord.com/developers/topics/gateway) | -| edit_message | Yes — a bot may edit its own messages via Edit Message. | [Message](https://docs.discord.com/developers/resources/message) | -| delete_message | Own: always. Others': requires MANAGE_MESSAGES permission. | [Message](https://docs.discord.com/developers/resources/message) | -| rich_content | Markdown, embeds, and message components (buttons, string/select menus, action rows). | [Message](https://docs.discord.com/developers/resources/message) | -| attachments | Inbound & outbound arbitrary file types. Default upload cap **10 MiB per file** (raised by uploader Nitro status or server Boost tier). | [Reference](https://docs.discord.com/developers/reference) | -| message_length_limit | 2000 characters per message content. | [Channel](https://docs.discord.com/developers/resources/channel) | -| dm_support | Yes — 1:1 DM channels (private channels). | [Channel](https://docs.discord.com/developers/resources/channel) | -| group_model | Guild → channels (GUILD_TEXT, forum, announcement, voice) → threads. Plus DM / group-DM private channels. Threads are channels with a `parent_id`. | [Channel](https://docs.discord.com/developers/resources/channel) | -| group_sender_identity | Yes — stable per-user snowflake `author.id` on every message; not consent-gated. Requires the MESSAGE_CONTENT intent to also read message text. | [Message](https://docs.discord.com/developers/resources/message) · [Gateway](https://docs.discord.com/developers/topics/gateway) | -| send_model | push. No reply-window/TTL — a bot with channel access may send at any time via REST. Replies are opt-in via `message_reference{message_id}`. | [Message](https://docs.discord.com/developers/resources/message) | -| proactive_push | Yes — unsolicited sends allowed within permissions. Global cap **50 requests/sec/bot**; per-route buckets (`X-RateLimit-Bucket`); invalid-request cap 10,000/10min. | [Rate Limits](https://docs.discord.com/developers/topics/rate-limits) | -| bot_to_bot | Yes — the gateway delivers other bots' messages; the `author.bot` flag distinguishes them (the bot's own messages arrive too and must be self-filtered). | [Gateway](https://docs.discord.com/developers/topics/gateway) | -| typing_indicator | Supported — `POST /channels/{id}/typing` (Trigger Typing); inbound `TYPING_START` event. | [Gateway](https://docs.discord.com/developers/topics/gateway) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | `ChannelId::say`; `resolve_channel` prefers `thread_id` over `channel_id`; `message_limit()` = 2000. | `discord.rs:70`, `discord.rs:55`, `discord.rs:66` | -| message_split/chunking | implemented | Router reads `message_limit()` (2000) then splits: `split_delivery` handles directive/body, `format::split_message` chunks the body, mentions are propagated to each chunk. | `adapter.rs:686`, `adapter.rs:1105`, `adapter.rs:149` | -| streaming | implemented | Post-then-edit, not native. Discord uses the edit-loop: `use_streaming` returns true only when no other bot is present (`!other_bot_present`); the router consults it at dispatch. `uses_native_streaming` stays default false, so the trait's native stream methods only hit their edit-based fallbacks. | `discord.rs:136`, `adapter.rs:687`, `adapter.rs:361`, `adapter.rs:380` | -| reply/quote | implemented | `send_message_with_reply` sets `reference_message`; falls back to plain send on invalid id (parses to 0) or reply failure (unknown/cross-channel message). | `discord.rs:83` | -| edit_message | implemented | Native `EditMessage.content` (overrides the trait default that returns "edit_message not supported"). | `discord.rs:123`, `adapter.rs:330` | -| delete_message | implemented | Native `http.delete_message` (overrides trait default which edits to a zero-width space `\u{200b}`). | `discord.rs:114`, `adapter.rs:353` | -| emoji_reactions | implemented | `add_reaction` = `create_reaction`, `remove_reaction` = `delete_reaction_me`. Unicode emoji only. | `discord.rs:164`, `discord.rs:177` | -| threads/topics | implemented | `create_thread` builds a thread from the trigger message via serenity `create_thread_from_message` (1-day auto-archive); auto-thread on first channel message via `get_or_create_thread`. Not the gateway `create_topic` path — the native adapter creates threads directly. | `discord.rs:140`, `discord.rs:2725` | -| media_inbound | implemented | Attachments processed inline in the per-attachment loop: images encoded (`download_and_encode_image`), text files (≤1 MB total, ≤5 files), video passed as a URL block; non-image files warned to the user. | `discord.rs:850`, `discord.rs:911` | -| voice_stt | implemented | Audio attachments transcribed via `media::download_and_transcribe` when `stt_config.enabled`; transcript injected + echoed; 🎤 reaction when STT disabled. | `discord.rs:852`, `discord.rs:855`, `discord.rs:883` | -| trust_gate | implemented | Two layers: adapter-level channel/user allowlist (`allowed_channels`, `is_denied_user`) + shared L3 identity gate `router.gate_incoming` (humans only; bots bypass via `l3_gate_applies`). | `discord.rs:803`, `discord.rs:1048`, `discord.rs:2922` | -| deny_echo | partial | On a denied user the bot reacts 🚫 on the offending message and drops it — no text reply (Discord L3 denies drop silently apart from the reaction). | `discord.rs:803` | -| mention_gating | implemented | `AllowUsers` modes: Mentions (always require @), Involved (skip @ if bot owns/participated in thread), MultibotMentions (require @ when other bots present). DMs treated as an implicit mention. | `discord.rs:759`, `discord.rs:456` | -| slash_commands | implemented | Global commands registered on `ready` via `set_global_commands`: /models, /agents, /cancel, /cancel-all, /reset, /remind, /auth, /export-thread; dispatched via `interaction_create`. | `discord.rs:1334`, `discord.rs:1386`, `discord.rs:1423` | -| multibot | implemented | Early other-bot detection cached (disk-persisted, irreversible); disables streaming, gates via MultibotMentions, enforces bot-turn limits; `trusted_bot_ids` + @mention admits handoff regardless of `allow_bot_messages`. | `discord.rs:331`, `discord.rs:593`, `discord.rs:2947` | -| group_routing | implemented | Per-thread dispatch keyed by `dispatcher.key("discord", channel_id, sender_id)`; thread↔parent allowlist via `detect_thread`; ambient mode buffers passive-channel messages. | `discord.rs:1066`, `discord.rs:2901`, `discord.rs:530` | - -## 3. Platform quirks (`platform-quirks`) - -### Threads are channels -A Discord thread has its own `channel_id`; the adapter resolves outbound targets via `thread_id.unwrap_or(channel_id)` (`resolve_channel`, `discord.rs:55`). Thread identity is `thread_metadata.is_some()` — `parent_id` alone is NOT reliable (category children also carry `parent_id`), so `detect_thread` returns early unless `has_thread_metadata`, and only uses `parent_id` for the allowlist check (`discord.rs:2901`). - -### Self-echo and bot-loop control -The bot receives its own messages over the gateway and must self-filter (`msg.author.id == bot_id`, `discord.rs:443`). Because multiple bots can ping-pong, there are layered guards: a hard consecutive-bot cap (`MAX_CONSECUTIVE_BOT_TURNS = 1000`), a configurable soft per-thread `max_bot_turns` reset by any human message, and `BotTurnTracker`. Bot-turn counting deliberately runs *before* the self-check (`discord.rs:341`) so all bot messages count, but warning posts respect the channel allowlist + prior participation to avoid uninvolved bots spamming (`discord.rs:402`). - -### Multibot detection is irreversible & disk-cached -Once any other bot posts in a channel/thread, that thread is permanently "multibot": cached in-memory and persisted to `MultibotCache` on disk (survives restarts), since bot messages don't disappear (`discord.rs:331`, `discord.rs:227`). This flips streaming off (the edit-loop interferes across bots) and can require @mention under `MultibotMentions`. - -### Streaming is post-then-edit, not native -Unlike Slack, Discord has no native streaming API; OpenAB streams by editing a placeholder message. `use_streaming` disables this whenever another bot is present, to avoid edit interference (`discord.rs:136`, #534). `uses_native_streaming` stays false (`adapter.rs:361`), so the trait's native stream methods only ever hit their edit-based fallbacks. - -### create_topic vs create_thread -The shared gateway layer has a `create_topic` command (`gateway.rs:513`) for gateway-protocol adapters. The native Discord adapter does NOT use it — it calls serenity `create_thread_from_message` directly (`discord.rs:148`) and auto-creates a thread for top-level channel messages via `get_or_create_thread`. - -### Attachment handling caps -Text-file attachments are bounded independently of Discord's own 10 MiB upload cap: 1 MB total across all text files (`TEXT_TOTAL_CAP`) and max 5 files per message (`TEXT_FILE_COUNT_CAP`), enforced with a Discord-reported-size pre-check before download (`discord.rs:847`, `discord.rs:887`). Image URLs from Discord expire ~24h, which is surfaced to the agent in the injected block (`discord.rs:925`). - -### DMs -DM channels can't hold threads, so DMs reuse the DM channel directly and are treated as an implicit @mention; gated only by `allow_dm` + user allowlist (`should_process_dm` / `should_skip_thread_creation`, `discord.rs:680`, `discord.rs:967`). - -### Findings log -- 2026-07-04 (A) Default file-upload cap is 10 MiB/file (raised by Nitro/Boost); the adapter's own text-attachment caps (1 MB total / 5 files) are stricter. [Reference](https://docs.discord.com/developers/reference) -- 2026-07-04 (A) Global REST rate limit is 50 req/sec/bot plus per-route buckets (`X-RateLimit-Bucket`); invalid requests capped at 10,000/10min — relevant to proactive-push and streaming edit-loop cadence. [Rate Limits](https://docs.discord.com/developers/topics/rate-limits) -- 2026-07-04 (A) Message content hard limit is 2000 chars, matching `DiscordAdapter::message_limit()`; the router chunks longer replies. [Channel](https://docs.discord.com/developers/resources/channel) -- 2026-07-04 (A) Thread channel types are ANNOUNCEMENT_THREAD (10) / PUBLIC_THREAD (11) / PRIVATE_THREAD (12), API v9+; identified by `thread_metadata`, not `parent_id` (category children also carry `parent_id`); `detect_thread` follows this. [Channel](https://docs.discord.com/developers/resources/channel) -- 2026-07-04 (A) Transport is a bot-initiated persistent WebSocket Gateway (WSS) authenticated by Identify(op 2) + bot token + intents — no per-event inbound signature to verify (unlike webhook platforms). [Gateway](https://docs.discord.com/developers/topics/gateway) -- 2026-07-04 (B) Section-2 ref audit: chunking lives at `adapter.rs:686`/`:1105` (not the trait def near `:306`); slash-command registration is `set_global_commands` at `discord.rs:1386`; `is_denied_user` at `discord.rs:2922`, trusted-bot bypass at `discord.rs:2947`. Corrected stale line refs. [PR #1295] diff --git a/docs/platforms/feishu.md b/docs/platforms/feishu.md deleted file mode 100644 index 7e5f41fd2..000000000 --- a/docs/platforms/feishu.md +++ /dev/null @@ -1,90 +0,0 @@ -# Feishu / Lark — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the Feishu / Lark adapter. For operator setup see `docs/feishu.md`. Follows the schemas in [`README.md`](./README.md). - -The adapter lives in the **gateway** crate (`crates/openab-gateway/src/adapters/feishu.rs`), not in a core `ChatAdapter` impl. Core talks to a generic `GatewayAdapter` (`crates/openab-core/src/gateway.rs`) over a WebSocket reply/response protocol; the gateway process runs the actual Feishu API calls. `domain=feishu` → `open.feishu.cn`, `domain=lark` → `open.larksuite.com` (`api_base()` `feishu.rs:290`). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | Both: **WebSocket long-connection** (default; protobuf `pbbp2.Frame`, `feishu.rs:24`, ACK per event) and **webhook** (`FEISHU_CONNECTION_MODE=webhook`). Ingested event: `im.message.receive_v1`. | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | -| inbound_auth | Webhook: L1 = **SHA256(timestamp + nonce + encrypt_key + body)** header signature (`verify_signature` `feishu.rs:3229`) + optional constant-time `verification_token`; event body **AES-256-CBC** with key = **SHA256(encrypt_key)**, IV = first 16 bytes of the ciphertext (`decrypt_event` `feishu.rs:3250`). WS: auth via AppID+AppSecret handshake to fetch the endpoint. | [Encrypt/verify events](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case) | -| threads | **Native.** Messages carry `root_id` / `parent_id`; reply via `POST /im/v1/messages/{id}/reply` with optional `reply_in_thread=true` (default `false`). If the parent is already a thread, replies stay in-thread automatically. `parent_id` for a thread always points at the thread root. | [Reply message](https://open.feishu.cn/document/server-docs/im-v1/message/reply) | -| slash_commands | **No native slash commands.** Bots have a **custom menu** (`application.bot.menu_v6` / menu-click event; ≤3 main + ≤5 sub, **DM-only**, no group support). Command-style input is just plain text. | [Bot customized menu](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu) | -| mentions | `@mention` via `event.message.mentions[]` (each has a `key` placeholder like `@_user_1` in text + `id.open_id`). Bot detects itself by matching its own `open_id` (resolved via `/bot/v3/info` `feishu.rs:916`). | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | -| emoji_reactions | Bot **can add** (`POST /im/v1/messages/{id}/reactions`) and **remove** (list → find own `reaction_id` → `DELETE`). Bot **can receive** reaction events via `im.message.reaction.created_v1` / `im.message.reaction.deleted_v1` (subscription required). | [Add reaction](https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create) · [reaction.created event](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message-reaction/events/created) | -| edit_message | **Yes**, own message only (calling identity must equal sender, else errcode 230071). `PATCH /im/v1/messages/{id}` updates `text` / `post`. Officially documented **hard cap of 20 edits per message** ("一条消息最多可编辑 20 次"), returning **errcode 230072** when exceeded; edit-time window is **set by the enterprise admin** (errcode 230075 when the window is exceeded — *not* a fixed 14-day limit). Card (`interactive`) updates use a separate endpoint and are not subject to the 20-edit cap. Global API frequency limit 1000/min · 50 QPS (errcode 230020). | [Edit message](https://open.feishu.cn/document/server-docs/im-v1/message/update) | -| delete_message | **Yes**, own message, `DELETE /im/v1/messages/{id}` (recall). Not subject to the edit cap. Deleting others' messages is admin/permission-gated, not a bot capability. | [Recall message](https://open.feishu.cn/document/server-docs/im-v1/message/delete) | -| rich_content | `post` (rich text: text/link/at/img/code_block), `interactive` **CardKit v2** cards (buttons, markdown, tables, streaming). Plain markdown is **not** natively rendered as a text message — must be converted to `post` or a card. | [Post content](https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json) · [Card JSON v2](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/feishu-cards/card-json-v2-structure) | -| attachments | Inbound: image / file / audio / media / post-embedded images; downloaded via `/im/v1/messages/{id}/resources/{key}`. Outbound: images/files uploaded first, then referenced by key. Feishu file-size limits are large (image ~10 MB, file ~30–100 MB tier-dependent). | [Get message resource](https://open.feishu.cn/document/server-docs/im-v1/message/get-2) | -| message_length_limit | **text ≤ 150 KB**; **post / card ≤ 30 KB** (per `content` JSON; card templates / style tags can inflate rendered size beyond the request body). | [Send message](https://open.feishu.cn/document/server-docs/im-v1/message/create) | -| dm_support | Yes — `chat_type = "p2p"`. | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | -| group_model | `chat` (group). Event `chat_type`: `p2p` (DM) vs group; each has a stable `chat_id` (`oc_…`). No separate channel/thread taxonomy — threads are message-level (`root_id`). | [Chat concepts](https://open.feishu.cn/document/server-docs/group/chat/chat-overview) | -| group_sender_identity | **Yes, stable & always present.** `sender.sender_id.open_id` (`ou_…`) is a per-app stable user id delivered in every group event; not consent-gated (display-name lookup via Contact API may need scope). | [Receive message event](https://open.feishu.cn/document/server-docs/im-v1/message/events/receive) | -| send_model | **Push model** — `POST /im/v1/messages` (or `/reply`) with a `tenant_access_token` (TTL ~7200 s, auto-refreshed). No per-message reply-window / token TTL like LINE; any message can be sent as long as the bot is in scope / in the group. The reply API's `uuid` is only a 1-hour idempotency window, not a reply deadline. | [Send message](https://open.feishu.cn/document/server-docs/im-v1/message/create) · [tenant_access_token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal) | -| proactive_push | Yes, to users **within the bot's availability scope** and groups the bot has joined (with speaking permission). Rate limits: **5 QPS per same-user**, **5 QPS shared per same-group**, global **1000/min · 50 QPS**. User opt-out → errcode 230053; user outside availability scope / disabled → errcode 230013. No hard daily quota. | [Send message rate limits](https://open.feishu.cn/document/server-docs/im-v1/message/create) | -| bot_to_bot | **Effectively no.** Feishu does **not** push another bot's messages to a bot's WebSocket, and marks other bots' messages as `sender_type="user"`, so a peer bot is indistinguishable without an explicit id allowlist. *(Platform docs are silent on bot→bot event delivery — behavior verified only via the adapter, not an official statement.)* | Adapter comment `feishu.rs:1158`, `parse_message_event` `feishu.rs:412` · [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | -| typing_indicator | **Not exposed** to bots — no public "typing"/"inputting" API is documented (Message FAQ is silent on it). OpenAB signals progress with emoji reactions / a streaming card instead. *(Absence confirmed by omission; cannot be stated as an affirmative platform guarantee.)* | [Message FAQ](https://open.feishu.cn/document/server-docs/im-v1/faq) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | Replies sent as **`post`** (rich text) so markdown renders; `send_post_message`. `send_text_message` kept only for webhook fallback/tests (`#[allow(dead_code)]`). | `send_post_message` `feishu.rs:2160`, `send_text_message` `feishu.rs:2230` | -| message_split/chunking | implemented | Long replies chunked at `message_limit` (default **4000**, `FEISHU_MESSAGE_LIMIT` `feishu.rs:228`) via `split_text` (UTF-8-safe, breaks on newline/space); partial-chunk failure is reported as failure to core. | chunk branch `feishu.rs:2701`, `split_text` call `feishu.rs:2707`, def `feishu.rs:3158` | -| streaming | workaround | No native content-streaming API. Emulated by **PATCH-editing a `post`** in place, with an **auto post→card promotion**: `StreamingMode::Auto` (default) promotes to a CardKit v2 streaming card when text is large (`card_promote_bytes`, default 4000), or contains a code fence / markdown table (`should_use_card`), or when the 20-edit post cap is hit (in `handle_card_edit`). `post` mode = kill-switch. Idle reaper finalizes cards. | `should_use_card` `feishu.rs:1550`, `handle_card_edit` `feishu.rs:2799`, `run_idle_reaper` `feishu.rs:3105` | -| reply/quote | implemented | `send_message_with_reply` → gateway `quote_message_id`; adapter prefers `quote_message_id` over `thread_id`, uses `/messages/{id}/reply`, and falls back to a plain send if reply-to fails. | core `send_message_with_reply` `gateway.rs:481`; adapter reply-target `feishu.rs:2645`, reply-path `feishu.rs:2168` | -| edit_message | implemented | Real in-place PATCH (`edit_feishu_message`). Core overrides the trait default (which returns "unsupported") because Feishu acks writes: `edit_message` uses an 800 ms request/response so it can see cap signals. Preemptive local cap at 18 edits (`FEISHU_EDIT_CAP` `feishu.rs:1259`, 2-edit safety margin) + server errcode 230072 detection. | `edit_feishu_message` `feishu.rs:1385`; core edit path `gateway.rs:585`; trait default `adapter.rs:330` | -| delete_message | implemented | Native `DELETE /im/v1/messages/{id}` (`delete_feishu_message`); core overrides the trait default (edit-to-zero-width) because Feishu has a real delete. Used to remove the half-edited placeholder during cap-recovery / card-promotion. | `delete_feishu_message` `feishu.rs:1496`; core override `gateway.rs:665`; trait default `adapter.rs:353` | -| emoji_reactions | partial | `add_reaction` implemented; `remove_reaction` implemented via list→match-own-`reaction_id`→DELETE. **Limited to an 8-emoji hard-coded map** (👀🤔🔥👨‍💻⚡🆗👍😱); anything else is silently dropped. Inbound reaction **events are not ingested** (adapter only parses `im.message.receive_v1`). | `emoji_to_feishu_reaction` `feishu.rs:2289`, `add_reaction` `feishu.rs:2303`, `remove_reaction` `feishu.rs:2328`; core dispatch `gateway.rs:541`/`:563` | -| threads/topics | partial | Native thread replies work (via `root_id` / `reply`). But the gateway `create_topic` command (from core `create_thread`) is a **no-op** — the Feishu adapter explicitly skips it and core falls back to replying in the same channel. | skip: `feishu.rs:2578`; core create_thread→create_topic `gateway.rs:490` | -| media_inbound | implemented | Images (resized to ≤1200 px, JPEG q75, ≤10 MB; GIF passthrough), text files (extension-allowlisted, ≤512 KB), audio (≤25 MB, Whisper cap), and `post`-embedded images. Oversized/unsupported → `Attachment::rejected`. Constants at `feishu.rs:1786`–`1789`, `:2049`. | `download_feishu_image` `feishu.rs:1820`, `download_feishu_file` `feishu.rs:1933`, `download_feishu_audio` `feishu.rs:2052` | -| voice_stt | partial | Adapter only **downloads** audio into an `audio` attachment (≤25 MB); actual speech-to-text is done downstream (Whisper) by core, not in the adapter. | `download_feishu_audio` `feishu.rs:2052` (`AUDIO_MAX_DOWNLOAD` `feishu.rs:2049`) | -| trust_gate | implemented | Two layers: **adapter-side** `allowed_users` / `allowed_groups` allowlists + bot/self filtering in `parse_message_event` (`feishu.rs:425` user allowlist, `feishu.rs:444` group allowlist); **core-side** shared ingress `gate_incoming` (L2 scope + L3 identity) before dispatch. | adapter `feishu.rs:425`; core `gate_incoming` call `gateway.rs:1198`, impl `adapter.rs:495` | -| deny_echo | implemented | On core L3 `DenyIdentity`, gateway echoes a throttled deny message ("⚠️ You are not on this bot's trusted list.\nYour ID: …\nAsk the admin to add it to allowed_users."), ≤1 echo per platform:sender per window. `DenyScope` → silent drop. | `gateway.rs:1201` (message text `:1221`, silent DenyScope `:1228`) | -| mention_gating | implemented | Groups require @mention unless bot has participated in the thread (Discord-style "involved"). Modes via `FEISHU_ALLOW_USER_MESSAGES`: `involved` / `mentions` / `multibot_mentions` (default — require @mention if another bot is in the thread). Participation cache TTL = `FEISHU_SESSION_TTL_HOURS` (24 h). | `parse_message_event` gate `feishu.rs:569`; `detect_and_mark_multibot` `feishu.rs:2408` | -| slash_commands | n/a | Platform has no slash-command system; `/reset` and `/cancel` arrive as plain text and are intercepted by **core** (not the adapter). Adapter does no slash parsing. | core interception `gateway.rs:1376` (`/reset`) / `gateway.rs:1389` (`/cancel`); platform: no native support | -| multibot | partial | `FEISHU_ALLOW_BOTS` (off/mentions/all) + `FEISHU_TRUSTED_BOT_IDS` + per-chat `max_bot_turns` (default 20) loop-guard. **Caveat:** Feishu marks other bots as `sender_type="user"` and doesn't push bot messages over WS, so without `trusted_bot_ids` peer bots can't be identified; `multibot_mentions` detection is done via **@mention of a known bot id**, not sender type. | `parse_message_event` `feishu.rs:417`, bot-turn guard `feishu.rs:1142`, `detect_and_mark_multibot` `feishu.rs:2408` | -| group_routing | implemented | Routing keyed by `chat_id`; `channel_type` = `direct` (p2p) vs `group`; `thread_id` from `root_id` / `parent_id`. Dedupe by `event_id` + `message_id`; self-echo dedupe on sent `message_id` (Feishu pushes the bot's own messages back). | `parse_message_event` `feishu.rs:557`, dedupe `feishu.rs:1116` / `:1138` | - -## 3. Platform quirks (`platform-quirks`) - -### 20-edit-per-message cap → streaming card promotion - -Feishu **officially documents** a hard limit of 20 edits per message: `PATCH /im/v1/messages/{id}` for `text`/`post` returns **errcode 230072** once exceeded ("一条消息最多可编辑 20 次" / "the message has reached the number of times it can be edited"). This is the central constraint shaping OpenAB streaming. The adapter (a) preemptively stops at 18 edits (`FEISHU_EDIT_CAP` `feishu.rs:1259`, 2-edit safety margin for in-flight races), (b) parses the body `code` (JSON-code-first, substring fallback) to catch server-side 230072 even on HTTP 200, and (c) on cap, promotes to a **CardKit v2 streaming card** (no such cap) or, in `post` mode, lets core's finalize path delete the placeholder and send fresh content. Card-update uses its own 5-QPS/message frequency limit (errcode 230020) instead. (The edit-*time* window is separately admin-configured, errcode 230075.) - -### post-vs-card streaming state machine - -`FEISHU_CARD_STREAMING_MODE` = `auto` (default) | `card` | `post`. In `auto`, short replies stay a native `post` reply (nicer thread UI); long / code-fence / table replies (which `markdown_to_post` degrades — tables are dropped entirely, Issue #1124) promote to a card (`should_use_card` `feishu.rs:1550`, with `has_code_fence` / `has_markdown_table`). The post→card swap is invisible to core: the gateway keeps reporting the original `om_post` message_id back so core's edit loop is oblivious. `FEISHU_CARD_FALLBACK_TO_POST=true` (default) is a second safety net back to the post path on any card failure. - -### markdown rendering is lossy - -`markdown_to_post` (`feishu.rs:1593`) converts to Feishu `post`: code fences → `code_block`, `[t](url)` → `a`, and **bold/italic/strike markers are stripped** (not rendered). **Markdown tables are not representable** in `post` at all — that's a primary reason the auto-promotion to CardKit exists (cards render tables/markdown natively). - -### message_id shape validation (defence-in-depth) - -Any command that interpolates a message_id into a REST path (`edit_message`, `delete_message`, `add_reaction`, `remove_reaction`) is gated by `is_valid_feishu_message_id` (`feishu.rs:1270`; `om_` + `[A-Za-z0-9_]`, len 4–128), rejecting crafted ids containing `/ ? #`. Internal card message_ids from trusted gateway session state bypass the check. The `"draft"` sentinel is a benign no-op skip. - -### bot identity & self/peer-bot handling - -Bot `open_id` is resolved at startup and on WS reconnect via `/bot/v3/info` (`feishu.rs:916`); without it, mention gating can't work. Feishu pushes the bot's **own** sent messages back over WS (handled by self-echo dedupe on the returned `message_id`) and does **not** push *other* bots' messages to a bot — so multibot detection relies on @mentions of known bot ids, not on `sender_type` (which is `user` for peer bots). - -### webhook security posture - -Webhook path enforces: 1 MB body cap (`feishu.rs:3192`), per-IP rate limit, SHA256 signature (only if `FEISHU_ENCRYPT_KEY` set — a warning is logged and verification is **skipped** if unset), AES-256-CBC decrypt of `encrypt` payloads (key = SHA256(encrypt_key), IV = first 16 bytes), constant-time `verification_token` check, and the URL-verification `challenge` handshake. - -### Findings log - -- 2026-07-04 (A) **20-edit-per-message hard cap is officially documented** (errcode 230072); edit is sender-only (230071); edit-time window is admin-configured (230075) — *not* a fixed 14-day/230031 limit as previously drafted. Global 1000/min·50 QPS, per-message 5 QPS (230020). [https://open.feishu.cn/document/server-docs/im-v1/message/update] -- 2026-07-04 (A) Text content ≤150 KB; post/card ≤30 KB; 5 QPS/user & 5 QPS/group shared, 1000/min·50 QPS global; user opt-out → 230053, out-of-scope → 230013. [https://open.feishu.cn/document/server-docs/im-v1/message/create] -- 2026-07-04 (A) Reply API supports native threads via `reply_in_thread` (default false) + `root_id`/`parent_id`; no reply-time deadline (the `uuid` is a 1-hour idempotency window only). [https://open.feishu.cn/document/server-docs/im-v1/message/reply] -- 2026-07-04 (A) Bot can add reactions and remove by reaction_id; inbound reaction events exist as `im.message.reaction.created_v1`/`deleted_v1` (subscription required) — OpenAB adapter does not yet ingest them. [https://open.feishu.cn/document/server-docs/im-v1/message-reaction/create] -- 2026-07-04 (A) No native slash commands; only a DM-only custom bot menu (≤3 main/≤5 sub). OpenAB treats `/reset` etc. as plain text, intercepted in core. [https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/bot-v3/bot-customized-menu] -- 2026-07-04 (A) No public bot typing-indicator API and no documented bot→bot event delivery — Message FAQ is silent on both; OpenAB uses reactions/streaming card for progress and infers peer bots via @mention only. [https://open.feishu.cn/document/server-docs/im-v1/faq] -- 2026-07-04 (B) Adapter relies on the 230072 cap → CardKit streaming-card promotion; card streaming (S6) defaults to `auto`. [crates/openab-gateway/src/adapters/feishu.rs:1550, :2799] -- 2026-07-04 (B) `create_topic` (from core `create_thread`) is a deliberate no-op in the Feishu adapter — core falls back to same-channel reply. [crates/openab-gateway/src/adapters/feishu.rs:2578] -- 2026-07-04 (B) Reaction emoji support limited to an 8-entry hard-coded map; unmapped emojis are silently dropped. [crates/openab-gateway/src/adapters/feishu.rs:2289] -- 2026-07-04 (B) Slash commands `/reset` and `/cancel` are intercepted in core `gateway.rs` (:1376/:1389), not the adapter; `dispatch.rs` does not hold the literals. [crates/openab-core/src/gateway.rs:1376] diff --git a/docs/platforms/googlechat.md b/docs/platforms/googlechat.md deleted file mode 100644 index 73f64c155..000000000 --- a/docs/platforms/googlechat.md +++ /dev/null @@ -1,83 +0,0 @@ -# Google Chat — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the Google Chat adapter. For operator setup see `docs/googlechat.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | webhook (HTTP endpoint URL) or Pub/Sub; adapter accepts both envelope shapes. Google Chat calls the app on interaction events; app must respond within ~30 s. | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | -| inbound_auth | Bearer ID-token (JWT, RS256) in `Authorization` header, signed by Google, verified against Google JWKS (`https://www.googleapis.com/oauth2/v3/certs`). `iss=https://accounts.google.com`; `aud` verified against the app's configured audience (project number for HTTP-endpoint apps, or app URL); `email` claim must equal `chat@system.gserviceaccount.com`. | [Verify requests are from Google Chat](https://developers.google.com/workspace/chat/receive-respond-interactions) | -| threads | native. Spaces are either threaded or flat ("in-line"); reply targets a `thread.name`/`threadKey` with `messageReplyOption` (adapter uses `REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`). Thread field is ignored in un-threaded spaces. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | -| slash_commands | supported. Registered in Cloud console (APIs & Services → Google Chat API → Configuration → Add a command; `/name`, commandId **1–1000**). Delivered as a `MESSAGE` event carrying `message.slashCommand` / `message.annotation.slashCommand` with the matching `commandId`. | [Slash commands](https://developers.google.com/workspace/chat/commands) | -| mentions | In spaces the app is only invoked when @mentioned (or via slash command / app-added); mention text is stripped into `argumentText`. In DMs every message reaches the app. Mentions encoded as `` (`` for @all). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) · [Format messages](https://developers.google.com/workspace/chat/format-messages) | -| emoji_reactions | Reaction resource + create/delete/list methods exist, BUT `reactions.create`/`reactions.delete` **require USER authentication** — the REST reference states "Requires user authentication" with scopes `chat.messages.reactions.create` / `chat.messages.reactions` / `chat.messages`; **no app-auth alternative is listed**. A `chat.bot` app therefore **cannot add or remove** reactions. Apps can still receive `REACTION_ADDED`/`REACTION_REMOVED` interaction events. | [reactions.create (REST ref)](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create) · [Add a reaction](https://developers.google.com/workspace/chat/create-reactions) | -| edit_message | Yes — `spaces.messages.patch` with `updateMask=text`; with app auth an app can update **only messages it created**. Scope `chat.bot`. | [messages.patch](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch) | -| delete_message | Own only — `spaces.messages.delete`; with app auth an app can delete **only messages it created** (not user/other-app messages). Scope `chat.bot`. | [messages.delete](https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/delete) | -| rich_content | Native text formatting (`*bold*`, `_italic_`, `~strike~`, `` `code` ``, ```` ```block``` ````, `` links, `` mentions, bullet/quote). Cards v2 + interactive widgets/buttons/dialogs (app auth). | [Format messages](https://developers.google.com/workspace/chat/format-messages) | -| attachments | Inbound: user-uploaded attachments downloaded via Media API (`UPLOADED_CONTENT`; Drive-sourced needs the Drive API, not handled). Upload limit **200 MB**; some file types are blocked. Outbound app uploads also up to 200 MB (a message with an attachment can't also carry accessory widgets). | [Upload media as attachment](https://developers.google.com/workspace/chat/upload-media-attachments) | -| message_length_limit | Max message payload (text + cards) **32,000 bytes**; larger must be split into multiple messages. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | -| dm_support | Yes — 1:1 DM space between a user and the app (space type DM). App receives all DM messages (no mention required). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | -| group_model | "Spaces": DM (1:1), group chat (unnamed multi-person), and named spaces/rooms. Spaces are threaded or flat. Routing keyed on `space.name` (e.g. `spaces/AAAA`). | [Create messages](https://developers.google.com/workspace/chat/create-messages) | -| group_sender_identity | Yes, stable & non-consent-gated — every message event carries `sender` = user resource name `users/{id}` + `displayName` + `type` (HUMAN/BOT). | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | -| send_model | Push (REST `spaces.messages.create`) with app auth; no reply-token/TTL window. Interaction responses may also be returned synchronously in the webhook response body. | [Create messages](https://developers.google.com/workspace/chat/create-messages) | -| proactive_push | Yes, app can post proactively to spaces it belongs to. Quota: **3,000 message writes/min per project** (create+patch+delete combined), and **1 write/sec per space** (shared across all apps in the space; 10/sec only during data import). 429 `Too many requests` on overflow → truncated exponential backoff. | [Usage limits](https://developers.google.com/workspace/chat/limits) | -| bot_to_bot | Not delivered — the `MESSAGE` interaction event is defined as "A user messages a Chat app"; other apps'/bots' messages are not surfaced to a Chat app. (No doc positively states delivery; treat as unsupported. Adapter also drops `sender.type == "BOT"` inbound.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | -| typing_indicator | Not supported — Google Chat exposes no typing/composing API for apps; none of the documented app-facing interaction events or send surfaces include a typing signal. (Verified negative against the interactions surface, which enumerates the full set of app capabilities.) | [Receive & respond to interactions](https://developers.google.com/workspace/chat/receive-respond-interactions) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | `POST {space}/messages` with markdown→gchat conversion; returns message `name`. | `crates/openab-gateway/src/adapters/googlechat.rs:1060` | -| message_split/chunking | implemented | Splits at `GOOGLE_CHAT_MESSAGE_LIMIT = 4096` (conservative vs the 32 KB API cap) on newline/space boundaries, UTF-8-safe; each chunk a separate message; first message id / first error acked. | `crates/openab-gateway/src/adapters/googlechat.rs:14`, `:371`, `:1112` | -| streaming | partial | No native streaming API. Gateway adapter leaves `uses_native_streaming=false`, so core streams via the post-then-`edit_message` loop; each edit sends the full accumulated text as a `patch` call. | adapter `edit_message` `crates/openab-gateway/src/adapters/googlechat.rs:300`; trait defaults `crates/openab-core/src/adapter.rs:361,380` | -| reply/quote | partial | No dedicated quote/reply-to. `send_message_with_reply` uses the trait default → plain `send_message`; thread continuity is via `thread_id`, not a per-message quote. | trait default `crates/openab-core/src/adapter.rs:336`; adapter send `crates/openab-gateway/src/adapters/googlechat.rs:1075` | -| edit_message | implemented | `PATCH {message_name}?updateMask=text`; core gates the streaming edit rate on the ack. Own messages only (platform rule). | adapter `crates/openab-gateway/src/adapters/googlechat.rs:300`; core dispatch (`command:"edit_message"`) `crates/openab-core/src/gateway.rs:623` | -| delete_message | not-implemented | GatewayAdapter overrides `delete_message` to emit a fire-and-forget `command:"delete_message"` (`gateway.rs:679`) instead of the trait default (edit-to-zero-width, `adapter.rs:353`). The googlechat adapter does **not** match `delete_message` in `handle_reply` (only `add_reaction`/`remove_reaction`/`create_topic`/`edit_message`), so it falls through to the send path with empty text → hits the empty-message short-circuit and sends nothing. Net: delete is a no-op on Google Chat. | adapter cmd match `crates/openab-gateway/src/adapters/googlechat.rs:335`, empty-msg short-circuit `:374`; core override `crates/openab-core/src/gateway.rs:679`; trait default `crates/openab-core/src/adapter.rs:353` | -| emoji_reactions | not-implemented | Core dispatches `add_reaction`/`remove_reaction` commands (`gateway.rs:541,563`) but the adapter early-returns on them (`match … => return`). Deliberate: Google Chat reactions require USER auth; the app (`chat.bot`) cannot react. Status/thinking reactions therefore never render on Google Chat. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:335`; core `crates/openab-core/src/gateway.rs:541,563` | -| threads/topics | partial | Inbound `thread.name` is captured (`:551`) and used to keep replies in-thread (send sets `thread.name` + `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`, `:1075`). `create_thread`→`create_topic` command (`gateway.rs:513`) is early-returned by the adapter, so explicit topic creation is a no-op and core falls back to the same channel (`gateway.rs:530`). | adapter `crates/openab-gateway/src/adapters/googlechat.rs:335`, `:551`, `:1075`; core `crates/openab-core/src/gateway.rs:490,513,530` | -| media_inbound | implemented | Async download via Media API after the 200 response: images (resize longest side ≤1200px, JPEG q75, ≤10 MB; GIF passthrough); text-like files (extension whitelist, ≤512 KB each, ≤5 files / ≤1 MB aggregate); audio (≤25 MB, stored raw). Drive-sourced & video skipped. | `crates/openab-gateway/src/adapters/googlechat.rs:1153`, `:1196`, `:1239`, `:1347`, `:1455` | -| voice_stt | partial | Adapter downloads audio and emits it as an `audio` attachment with real MIME; STT happens in core only if `stt_config.enabled`, else forwarded as a "transcription failed" note. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:1455`; core STT `crates/openab-core/src/gateway.rs:1340` | -| trust_gate | implemented | Two layers: (1) webhook JWT verify — RS256 via Google JWKS + `email==chat@system.gserviceaccount.com` (`googlechat.rs:150,218`); (2) shared ingress `router.gate_incoming` L2 scope + L3 identity in `process_gateway_event`. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:150,218`; core `crates/openab-core/src/gateway.rs:1196` | -| deny_echo | implemented | `DenyIdentity` → throttled request-access echo ("Your ID: …") via `adapter.send_message`; `DenyScope` → silent drop. Platform-agnostic; delivered through the normal Google Chat send path. | `crates/openab-core/src/gateway.rs:1201` | -| mention_gating | implemented (platform-native) | Google Chat itself only delivers space messages when the app is @mentioned. Core `should_skip_event` mention-gating only fires for `channel_type == "group"`/`"supergroup"` (`gateway.rs:72-81`); Google Chat spaces surface as `ROOM`/`SPACE`/`DM`, so the core `@mention` string check never triggers — gating is enforced by the platform, not core. | core `crates/openab-core/src/gateway.rs:56,72`; adapter space_type `crates/openab-gateway/src/adapters/googlechat.rs:547` | -| slash_commands | implemented (core-side) | `/reset`, `/cancel`, `/model(s)`/`/agents` intercepted in `process_gateway_event` before dispatch; responses sent via `adapter.send_message`. Not wired to Google Chat's native slash-command registration — plain-text commands. | `crates/openab-core/src/gateway.rs:1376`, `:1389`, `:1398` | -| multibot | partial | Bot senders are dropped inbound (`sender.user_type == "BOT"` → skip, `googlechat.rs:532`); core's multibot/other-bot streaming suppression can't observe other bots since Chat doesn't deliver their messages. `use_streaming` ignores `other_bot_present` for gateway adapters. | adapter `crates/openab-gateway/src/adapters/googlechat.rs:532`; core `crates/openab-core/src/gateway.rs:701` | -| group_routing | implemented | Session/route keyed on `space.name` + optional `thread_id`; ChannelInfo carries `id`=space, `channel_type`=space type, `thread_id`. | `crates/openab-gateway/src/adapters/googlechat.rs:697` | - -## 3. Platform quirks (`platform-quirks`) - -### Auth asymmetry: two independent credential paths -Inbound webhooks are authenticated by verifying Google's ID-token (`email==chat@system.gserviceaccount.com`, RS256 via JWKS, `iss=accounts.google.com`, audience-checked). Outbound API calls use a *separate* service-account → OAuth2 JWT-bearer exchange (`scope=https://www.googleapis.com/auth/chat.bot`), cached with a 300 s refresh margin. Missing outbound creds degrade to a logged dry-run that still acks failure to core. (`googlechat.rs:150,218,773,831,349`) - -### Reactions are structurally impossible for the bot -The single biggest divergence from other adapters: Google Chat's reaction API is **user-auth only** (the `reactions.create` REST reference explicitly lists only user-auth scopes). A `chat.bot` app cannot add/remove reactions at all, so OpenAB's status-reaction UX (👀 queued / 🤔 thinking / tool emojis / mood face) is silently dropped. The adapter deliberately early-returns on `add_reaction`/`remove_reaction`/`create_topic` rather than issuing doomed API calls. Consider a message-edit-based status line if a progress indicator is needed here. (`googlechat.rs:335`) - -### Markdown must be raw, once -`markdown_to_gchat` converts CommonMark → Chat syntax (`**b**`→`*b*`, `*i*`→`_i_`, `~~s~~`→`~s~`, `[t](u)`→``, headings→bold, code fences/inline code passthrough). It is applied by *both* `send_message` and `edit_message`; passing already-converted text double-converts (e.g. `*bold*` re-parsed as `*italic*`). Core must always emit raw markdown on streaming edits. (`googlechat.rs:860`, inline conversion `:907`) - -### 30-second webhook deadline vs. attachment downloads -Attachment-bearing messages spawn a background (panic-guarded) task and return `{}` immediately so the webhook meets Chat's ~30 s deadline; the GatewayEvent is emitted only after downloads finish. Text-only messages emit synchronously. Event dropped if both text and all attachments are empty/failed. (`googlechat.rs:577`) - -### Two webhook envelope shapes -Google Chat delivers either top-level (`message`/`user`/`space`) for HTTP-endpoint mode or wrapped under `chat.messagePayload` for Pub/Sub mode. The handler prefers the wrapped form and falls back to top-level. (`googlechat.rs:38,501`) - -### Chunk cap set below the API cap -Adapter chunks at 4096 chars while the API accepts 32,000 bytes — a conservative choice (matches the legacy client-visible limit) that produces more messages than strictly necessary for very long replies. (`googlechat.rs:14`) - -### Delete is a silent no-op (not even the edit fallback) -Unlike platforms where `delete_message` falls back to the trait's edit-to-zero-width, on Google Chat the `delete_message` command isn't matched in `handle_reply`, falls through to the send path with empty text, and hits the empty-message short-circuit — so nothing is sent and no edit occurs. Streaming-placeholder cleanup that relies on delete is therefore a no-op here. (`googlechat.rs:335,374`) - -### Findings log -- 2026-07-04 (A) `reactions.create`/`reactions.delete` require **user** auth (REST ref lists only `chat.messages.reactions*` / `chat.messages`, no app-auth); a `chat.bot` app cannot add/remove reactions → OpenAB status-reaction UX is a no-op on this platform. [https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages.reactions/create] -- 2026-07-04 (A) Max message payload (text+cards) is **32,000 bytes**; longer content must be split into multiple messages (adapter chunks conservatively at 4096 chars). [https://developers.google.com/workspace/chat/create-messages] -- 2026-07-04 (A) With app auth, `messages.patch` (edit) and `messages.delete` operate on **only the app's own** messages; edit `updateMask` supports `text`. [https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages/patch] -- 2026-07-04 (A) Attachment upload limit is **200 MB**; some file types are blocked (an attachment message can't also carry accessory widgets). OpenAB downloads far below this (image 10 MB / file 512 KB / audio 25 MB) and skips Drive-sourced & video attachments. [https://developers.google.com/workspace/chat/upload-media-attachments] -- 2026-07-04 (A) Rate limits: **3,000 message writes/min per project** and **1 write/sec per space** (shared across apps; 10/sec only during data import); 429 → truncated exponential backoff. [https://developers.google.com/workspace/chat/limits] -- 2026-07-04 (A) Slash commands are registered in the Cloud console with a **commandId 1–1000** and delivered as a `MESSAGE` event carrying `message.slashCommand` / `message.annotation.slashCommand`. [https://developers.google.com/workspace/chat/commands] -- 2026-07-04 (A) In spaces the app is only invoked when @mentioned (mention stripped into `argumentText`); DMs deliver every message — so mention-gating is enforced platform-side, not by OpenAB's `@mention` string check. [https://developers.google.com/workspace/chat/receive-respond-interactions] -- 2026-07-04 (A) The `MESSAGE` interaction event is defined as "A user messages a Chat app"; other bots'/apps' messages are not delivered, and there is no typing/composing API for apps. [https://developers.google.com/workspace/chat/receive-respond-interactions] -- 2026-07-04 (A) Inbound webhooks are signed by `chat@system.gserviceaccount.com` (RS256, JWKS at `oauth2/v3/certs`); OpenAB verifies issuer (`accounts.google.com`), audience, exp, and the `email` claim. [https://developers.google.com/workspace/chat/receive-respond-interactions] diff --git a/docs/platforms/line.md b/docs/platforms/line.md deleted file mode 100644 index 97c0656f8..000000000 --- a/docs/platforms/line.md +++ /dev/null @@ -1,91 +0,0 @@ -# LINE — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the LINE adapter. For operator setup see `docs/line.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | webhook (HTTPS POST to the bot's registered endpoint; events arrive as a batched `events[]` payload) | [Receive messages (webhook)](https://developers.line.biz/en/docs/messaging-api/receiving-messages/) | -| inbound_auth | HMAC-SHA256 over the raw request body, keyed by the channel secret, Base64-encoded, compared to the `x-line-signature` header | [Messaging API reference — signature validation](https://developers.line.biz/en/reference/messaging-api/#signature-validation) | -| threads | none — no native threads/topics. LINE has flat 1:1 chats, group chats and multi-person "rooms"; there is no thread or topic primitive | [Source objects](https://developers.line.biz/en/reference/messaging-api/#source-user) | -| slash_commands | not a platform feature — no command registration/delivery API. Any `/cmd` is just plain message text | [Message event](https://developers.line.biz/en/reference/messaging-api/#message-event) | -| mentions | `mention.mentionees[]` on text message events; each mentionee carries an optional `userId` and an `isSelf` flag that is `true` for the bot itself (no username matching needed) | [Text message event — mention](https://developers.line.biz/en/reference/messaging-api/#wh-text) | -| emoji_reactions | Bot **cannot add/remove** reactions (no API). Bot **cannot receive** them either: the documented webhook event list (message, unsend, follow/unfollow, join/leave, member join/leave, postback, video-viewing-complete, beacon, account-link, membership) contains **no `reaction` event** — verified against the current Messaging API reference, which does not surface a reaction webhook to bots | [Webhook event objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) | -| edit_message | No — the API has no endpoint to edit an already-sent message | [Messaging API reference](https://developers.line.biz/en/reference/messaging-api/) | -| delete_message | No bot-initiated delete. Only the *user* can unsend, which delivers an `unsend` webhook to the bot; the bot cannot delete its own or others' messages | [unsend event](https://developers.line.biz/en/reference/messaging-api/#unsend-event) | -| rich_content | Rich messages: stickers, images, imagemap, buttons/confirm/carousel templates, and Flex Messages (JSON-defined layouts). No Markdown; text is plain (LINE emoji via product/emoji IDs) | [Message types](https://developers.line.biz/en/docs/messaging-api/message-types/) | -| attachments | Inbound via get-content by message ID (`/v2/bot/message/{id}/content` on the `api-data.line.me` host): images, video, audio, files. `contentProvider.type` is `"line"` (fetchable) or `"external"` (URL only, not fetchable via get-content). User-sent content auto-expires, so fetch promptly. Outbound media is sent by URL, not upload | [Get content](https://developers.line.biz/en/reference/messaging-api/#get-content); [Message types](https://developers.line.biz/en/docs/messaging-api/message-types/) | -| message_length_limit | 5000 characters per text message object (counted in UTF-16 code units; chunking required above this) | [Text message object](https://developers.line.biz/en/reference/messaging-api/#text-message); [Character counting in a text](https://developers.line.biz/en/docs/messaging-api/text-character-count/) | -| dm_support | Yes — 1:1 chat between a user and the LINE Official Account | [Source objects](https://developers.line.biz/en/reference/messaging-api/#source-user) | -| group_model | Two multi-user taxonomies: **group** (`groupId`) and **room** / multi-person chat (`roomId`), plus 1:1 **user** chats. No channels/spaces | [Group chats and multi-person chats](https://developers.line.biz/en/docs/messaging-api/group-chats/) | -| group_sender_identity | Consent-gated and unreliable: `userId` is **optional** in group/room source objects and is only present for users on LINE for iOS/Android; it can be absent otherwise | [Source objects — group](https://developers.line.biz/en/reference/messaging-api/#source-group) | -| send_model | Hybrid: **Reply API** (`/v2/bot/message/reply`) consumes a one-time `replyToken` from the inbound webhook and is free; **Push API** (`/v2/bot/message/push`) targets a user/group/room ID at any time and counts against quota. Reply token must be used within ~1 minute; LINE explicitly says the limit may change without notice and use beyond one minute isn't guaranteed — don't rely on it. Both endpoints accept up to 5 message objects per request | [Send reply message](https://developers.line.biz/en/reference/messaging-api/#send-reply-message); [Send messages](https://developers.line.biz/en/docs/messaging-api/sending-messages/) | -| proactive_push | Yes, via Push API, but metered: each plan has a monthly free-message quota (amount depends on subscription plan/region), with paid overage. A get-quota/consumption API exists. Up to 5 message objects per request | [Messaging API pricing](https://developers.line.biz/en/docs/messaging-api/pricing/) | -| bot_to_bot | No — LINE Official Accounts (bots) do not receive messages from other bots; webhook message events are for user-originated content | [Messaging API overview](https://developers.line.biz/en/docs/messaging-api/overview/) | -| typing_indicator | Yes — "Display a loading animation" endpoint, **1:1 chats only** ("You can't specify group chats or multi-person chats"), rate-limited to 100 req/s. It is a loading animation while the user is viewing the chat, not a per-keystroke typing indicator | [Display a loading indicator](https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | Outbound text via hybrid dispatch: tries Reply API first (free), falls back to Push API. Only `{"type":"text"}` objects are sent (no rich content) | `crates/openab-gateway/src/adapters/line.rs:646` (`dispatch_line_reply`) [PR #1291] | -| message_split/chunking | partial | Router-level `split_delivery` + per-adapter `message_limit` handle length bounds generically; the LINE dispatcher itself sends a single text object per reply and does not re-chunk. LINE's own cap is 5000 chars/object | `split_delivery` `crates/openab-core/src/adapter.rs:149`; `message_limit` trait `crates/openab-core/src/adapter.rs:309`; single-object dispatch `crates/openab-gateway/src/adapters/line.rs:683-686` | -| streaming | not-implemented | No native streaming; adapter does not override `uses_native_streaming` (trait default false) and LINE has no edit API to drive post+edit streaming. Effectively send-once/batched | trait `uses_native_streaming` default `crates/openab-core/src/adapter.rs:361`; `edit_message` default `crates/openab-core/src/adapter.rs:330` | -| reply/quote | workaround | LINE "reply" is a delivery mechanism (reply-token), not a UI quote. `dispatch_line_reply` uses the token to answer in-context; there is no message-quote rendering. Reply vs Push chosen by token freshness | `crates/openab-gateway/src/adapters/line.rs:676-712` [PR #1291] | -| edit_message | n/a | LINE has no edit endpoint. Trait default `edit_message` returns `"edit_message not supported"`; adapter does not override | trait default `crates/openab-core/src/adapter.rs:330-332` | -| delete_message | n/a | No LINE delete endpoint. Trait default `delete_message` falls back to editing to a zero-width space — which also fails on LINE since edit is unsupported | trait default `crates/openab-core/src/adapter.rs:353-355` | -| emoji_reactions | n/a | LINE exposes no add/remove-reaction API. The gateway dispatcher explicitly ignores `add_reaction`/`remove_reaction` commands (logs "ignoring unsupported command", returns false) | `crates/openab-gateway/src/adapters/line.rs:653-659` [PR #1291] | -| threads/topics | n/a | LINE has no thread primitive. The `create_topic` command is explicitly ignored by the dispatcher alongside the reaction commands | dispatch `crates/openab-gateway/src/adapters/line.rs:653-659`; trait `create_thread` `crates/openab-core/src/adapter.rs:315` | -| media_inbound | partial | Images and audio are downloaded via get-content (`/v2/bot/message/{id}/content`), size-guarded (Content-Length pre-check + streaming cap), and stored (images are resized/compressed; audio stored as-is). **external**-provider content and missing access-token produce a status-only attachment (not dropped); video/files are not ingested (event filter passes only text/image/audio) | image `crates/openab-gateway/src/adapters/line.rs:401`; audio `crates/openab-gateway/src/adapters/line.rs:511`; type filter `crates/openab-gateway/src/adapters/line.rs:215`; external/missing-token `crates/openab-gateway/src/adapters/line.rs:228-264` | -| voice_stt | not-implemented | Audio is downloaded and stored as an attachment only; no speech-to-text is performed in the LINE path | `crates/openab-gateway/src/adapters/line.rs:511-639` | -| trust_gate | implemented | L1 signature at ingress (HMAC-SHA256 over raw body vs `x-line-signature`); shared L2 scope / L3 identity trust gate applies in the gateway ingress path, keyed by platform | L1 `crates/openab-gateway/src/adapters/line.rs:84-105`; trust gate `crates/openab-core/src/gateway.rs:1196-1200` [PR #1291] | -| deny_echo | workaround | On L3 identity-deny the gateway echoes the sender their ID via `adapter.send_message` (throttled per `platform:sender`). On LINE that echo flows through the same hybrid `dispatch_line_reply` keyed by the original event's reply token, so it is **Reply in practice** (deny happens at ingress while the token is still fresh); note this is not a hard "never Push" guarantee — if the token were expired the shared dispatcher would still fall back to Push. 1:1 echo includes the UID; group/room echo carries no stable ID | echo + throttle `crates/openab-core/src/gateway.rs:1201-1226`; dispatcher `crates/openab-gateway/src/adapters/line.rs:646-730`; Reply-preferred intent [PR #1291] | -| mention_gating | implemented | In group/room events the adapter drops the message unless a mentionee has `isSelf=true` (the bot). 1:1 DMs always pass. No env var / bot-name matching needed — LINE flags self-mention | `crates/openab-gateway/src/adapters/line.rs:371-378` [PR #1291] | -| slash_commands | n/a | LINE has no slash-command surface; commands would arrive as plain text. `/reset`, `/cancel` handling is not wired in the LINE path (events are filtered to text/image/audio and forwarded as-is) | `crates/openab-gateway/src/adapters/line.rs:215-217` | -| multibot | n/a | LINE does not deliver other bots' messages, and inbound `is_bot` is hard-coded `false`, so multi-bot coordination cannot trigger on LINE | `crates/openab-gateway/src/adapters/line.rs:391` [PR #1291] | -| group_routing | implemented | Channel keyed by `groupId` (group) / `roomId` (room) / `userId` (1:1); a group/room missing its ID is skipped. Sender falls back to `"unknown"` when `userId` is absent | `crates/openab-gateway/src/adapters/line.rs:325-354` [PR #1291] | - -## 3. Platform quirks (`platform-quirks`) - -### Reply/Push hybrid dispatch (the core LINE model) - -LINE splits outbound sending into two APIs with different economics. Every inbound webhook carries a one-time `replyToken`; using it (`/message/reply`) is free but the token is short-lived (~1 minute, officially "may change without notice" and not guaranteed beyond one minute). Push (`/message/push`) works anytime but consumes the monthly quota. OpenAB caches the token at webhook receipt time (TTL tracked from true receipt, `REPLY_TOKEN_TTL_SECS = 50` in `crates/openab-gateway/src/lib.rs:17`, deliberately under LINE's ~60s), tries Reply first, and only falls back to Push when the token is missing/expired. The cache is bounded (`REPLY_TOKEN_CACHE_MAX = 10_000`, `crates/openab-gateway/src/lib.rs:20`) and swept periodically (`crates/openab-gateway/src/lib.rs:460-466`). - -**Duplicate-safety bias:** on a Reply API error that is *not* a clearly-unusable-token 400 (e.g. network error, or a non-token 4xx/5xx), the dispatcher does **not** fall back to Push — it assumes the reply may have landed and returns `used_reply=true` to avoid double-sending (`crates/openab-gateway/src/adapters/line.rs:700-711`). Only an explicit "invalid … reply token" or "expired" 400 triggers Push fallback (`crates/openab-gateway/src/adapters/line.rs:697-699`). - -### Sender identity is best-effort, "unknown" is never trusted - -`userId` is optional in group/room sources (present only for LINE iOS/Android users). When absent, the sender collapses to the literal `"unknown"` (`crates/openab-gateway/src/adapters/line.rs:352-354`). Decision: `"unknown"` is **never allowlistable** — it cannot be added to `allowed_users`, because it is not a stable identity. Group admission for LINE is instead gated by self-mention (see below). [PR #1291] - -### @mention gating in groups/rooms - -In group/room events the adapter forwards the message only if some mentionee has `isSelf=true` — i.e. the bot itself was @-mentioned (`crates/openab-gateway/src/adapters/line.rs:372`). 1:1 DMs always pass. This relies entirely on LINE's `isSelf` flag, so no bot-name string matching or env var is needed. A group/room text with no `mention` object, or one where only other users are mentioned, is dropped. [PR #1291] - -### Early-ack webhook processing - -The webhook handler validates the signature, returns `200 OK`, then spawns background processing so slow image/audio downloads don't cause LINE to redeliver. Tradeoff (documented in-code at `crates/openab-gateway/src/adapters/line.rs:143-156`): once acked, a later crash is not retried by LINE, and cross-payload ordering can invert if an image event is slower than a following text event. A shared semaphore (`line_webhook_semaphore`, `LINE_WEBHOOK_CONCURRENCY_MAX`) bounds concurrent post-ack work to cap backlog under bursts; a saturated semaphore makes new webhooks wait before spawning (`crates/openab-gateway/src/adapters/line.rs:118-133`). - -### is_bot always false - -Inbound events hard-code `is_bot=false` (`crates/openab-gateway/src/adapters/line.rs:391`) because LINE never delivers other bots' messages to a bot. This means the shared multibot machinery is inert on LINE by construction. - -### External-content and unsupported media are surfaced, not silently dropped - -When an image/audio uses `contentProvider.type == "external"`, or the access token is unconfigured, the adapter emits an attachment with a `status` string (e.g. "unsupported format: external content not supported", "configuration error: service not configured") rather than dropping the event — so the agent still sees that media was present (image `crates/openab-gateway/src/adapters/line.rs:228-265`, audio `crates/openab-gateway/src/adapters/line.rs:270-316`). Video and generic files are filtered out earlier (only text/image/audio pass the type filter, `crates/openab-gateway/src/adapters/line.rs:215`). - -### Findings log - -- 2026-07-04 (A) No `reaction` webhook event in the documented Messaging API event list — bots can neither add/remove nor receive reactions; edit/delete endpoints also absent. Confirms reactions/edit/delete are n/a in OpenAB. [https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) -- 2026-07-04 (A) Reply token must be used within ~1 minute; LINE warns the limit may change without notice and use beyond one minute isn't guaranteed — motivates the conservative 50s TTL. Both Reply and Push accept up to 5 message objects per request. [https://developers.line.biz/en/docs/messaging-api/sending-messages/](https://developers.line.biz/en/docs/messaging-api/sending-messages/) -- 2026-07-04 (A) LINE text message objects cap at 5000 chars (counted in UTF-16 code units). [https://developers.line.biz/en/reference/messaging-api/#text-message](https://developers.line.biz/en/reference/messaging-api/#text-message) -- 2026-07-04 (A) Loading-animation ("typing") indicator is 1:1-only ("You can't specify group chats or multi-person chats"), rate-limited 100 req/s. [https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/](https://developers.line.biz/en/docs/messaging-api/use-loading-indicator/) -- 2026-07-04 (A) `userId` is optional in group/room sources and only present for LINE iOS/Android users — confirms "unknown" sender is intrinsic, not a bug. [https://developers.line.biz/en/reference/messaging-api/#source-group](https://developers.line.biz/en/reference/messaging-api/#source-group) -- 2026-07-04 (A) Inbound media is fetched via get-content by message ID on the `api-data.line.me` host; `contentProvider.type` is `line` (fetchable) or `external` (URL only); user content auto-expires — fetch promptly. [https://developers.line.biz/en/reference/messaging-api/#get-content](https://developers.line.biz/en/reference/messaging-api/#get-content) -- 2026-07-04 (A) Push is metered by a plan-dependent monthly free-message quota; Reply API is free — the economic basis for the hybrid model. [https://developers.line.biz/en/docs/messaging-api/pricing/](https://developers.line.biz/en/docs/messaging-api/pricing/) -- 2026-07-04 (B) deny-echo on LINE reuses the hybrid `dispatch_line_reply` keyed by the original event token: Reply in practice (fresh token at ingress), but not a hard "never Push" guarantee — an expired token would fall back to Push like any reply. [PR #1291] -- 2026-07-04 (B) LINE adapter design: L1 HMAC-SHA256 at ingress, group two-mode admission via `isSelf` mention-gating, "unknown" never allowlistable, `is_bot` always false, early-ack + semaphore-bounded background processing, single text object per reply. [PR #1291] - -**Open item (unknowable without running the platform):** the exact monthly Push free-message quota is plan/region-dependent and not a fixed documented constant — its numeric value must be read from the account's plan / the get-quota API at runtime rather than stated here. diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml index 5aacf43f3..7d6efbf36 100644 --- a/docs/platforms/schema/discord.toml +++ b/docs/platforms/schema/discord.toml @@ -1,5 +1,5 @@ # Discord platform capability schema. -# Generated from docs/platforms/discord.md — follows _template.toml. +# Generated from the adapter source + official docs — follows _template.toml. schema_version = "2026-07-07" diff --git a/docs/platforms/schema/feishu.toml b/docs/platforms/schema/feishu.toml index fbcd1d16a..3c3a32297 100644 --- a/docs/platforms/schema/feishu.toml +++ b/docs/platforms/schema/feishu.toml @@ -1,5 +1,5 @@ # Feishu / Lark — platform capability schema -# Generated from docs/platforms/feishu.md +# Generated from the adapter source + official docs schema_version = "2026-07-07" diff --git a/docs/platforms/schema/line.toml b/docs/platforms/schema/line.toml index be9ab8038..df7da01b8 100644 --- a/docs/platforms/schema/line.toml +++ b/docs/platforms/schema/line.toml @@ -1,6 +1,6 @@ # ═══════════════════════════════════════════════════════════════════════════ # LINE — machine-readable platform schema -# Converted from docs/platforms/line.md. Structure follows _template.toml. +# Converted from the adapter source + official docs. Structure follows _template.toml. # ═══════════════════════════════════════════════════════════════════════════ schema_version = "2026-07-07" diff --git a/docs/platforms/schema/slack.toml b/docs/platforms/schema/slack.toml index 9ea7ec5e5..62b343e54 100644 --- a/docs/platforms/schema/slack.toml +++ b/docs/platforms/schema/slack.toml @@ -1,5 +1,5 @@ # Slack — machine-readable platform schema -# Converted from docs/platforms/slack.md. See _template.toml for the schema. +# Converted from the adapter source + official docs. See _template.toml for the schema. schema_version = "2026-07-07" diff --git a/docs/platforms/schema/teams.toml b/docs/platforms/schema/teams.toml index 6e77304e9..06331335f 100644 --- a/docs/platforms/schema/teams.toml +++ b/docs/platforms/schema/teams.toml @@ -1,6 +1,6 @@ # ═══════════════════════════════════════════════════════════════════════════ # Platform capability schema — MICROSOFT TEAMS -# Generated from docs/platforms/teams.md. See _template.toml for the schema. +# Generated from the adapter source + official docs. See _template.toml for the schema. # ═══════════════════════════════════════════════════════════════════════════ schema_version = "2026-07-07" diff --git a/docs/platforms/schema/telegram.toml b/docs/platforms/schema/telegram.toml index 4eb013a00..6188c9939 100644 --- a/docs/platforms/schema/telegram.toml +++ b/docs/platforms/schema/telegram.toml @@ -1,4 +1,4 @@ -# Generated from docs/platforms/telegram.md — see docs/platforms/README.md for schemas. +# Generated from the adapter source + official docs — see docs/platforms/README.md for schemas. schema_version = "2026-07-07" diff --git a/docs/platforms/slack.md b/docs/platforms/slack.md deleted file mode 100644 index 530c3248a..000000000 --- a/docs/platforms/slack.md +++ /dev/null @@ -1,79 +0,0 @@ -# Slack — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the Slack adapter. For operator setup see `docs/slack.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | socket-mode (persistent WebSocket obtained from `apps.connections.open`; no public URL). | [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) | -| inbound_auth | None per-event. The WebSocket is pre-authenticated by the app-level token (`xapp-`) sent in the `Authorization` header to open it; inbound events need no HMAC/signature validation (explicitly unlike the HTTP Events API, where each event must be validated). | [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) | -| threads | native — a thread is implicit: any message posted with `thread_ts` = a parent message's `ts` becomes a reply in that thread. `reply_broadcast=true` optionally also surfaces the reply to the whole channel (default `false` = thread-only). | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | -| slash_commands | supported but **HTTP-only** — registered in app config with a Request URL; delivered via HTTP POST, not Socket Mode events. Developer slash commands **cannot be invoked in message threads** (only built-ins like `/remind` can). | [Implementing slash commands](https://docs.slack.dev/interactivity/implementing-slash-commands) | -| mentions | `@bot` renders in event text as `<@BOT_UID>` (or labelled `<@BOT_UID\|handle>`); a dedicated `app_mention` event also fires. DMs are an implicit mention (no `app_mention`). | [message event](https://docs.slack.dev/reference/events/message/) | -| emoji_reactions | Bot **can add** (`reactions.add`) and **remove** (`reactions.remove`) with `reactions:write`; **receives** `reaction_added`/`reaction_removed` events. Per-item caps on distinct emoji and per-person reactions. | [reactions.add](https://docs.slack.dev/reference/methods/reactions.add) | -| edit_message | Yes — `chat.update`, but **only messages the bot itself authored** (`cant_update_message` otherwise). Tier-3 rate limit; `edit_window_closed` if the workspace's message-edit settings forbid the edit. | [chat.update](https://docs.slack.dev/reference/methods/chat.update) | -| delete_message | `chat.delete` with `chat:write` — with a bot token, deletes **only messages posted by that bot**; cannot delete users'/other bots' messages (no impersonation accommodation). | [chat.delete](https://docs.slack.dev/reference/methods/chat.delete) | -| rich_content | Block Kit (sections, buttons, cards, and a `markdown` block) + legacy `mrkdwn`. A Block Kit `markdown` block renders real headings/lists/tables/code fences. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | -| attachments | Inbound: any file the bot can see, downloaded with the bot token (`files:read`). Outbound uploads capped at **1 GB per file** (hard limit on all plans); free workspaces additionally cap **total storage at 5 GB**. Note: `files.upload` is deprecated — **sunset 2025-11-12**, replaced by `files.getUploadURLExternal` + `files.completeUploadExternal`. | [1 GB / 5 GB limits](https://docs.slack.dev/changelog/2019-03-wild-west-for-files-no-more/) · [files.upload retirement](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/) | -| message_length_limit | `text`: recommended ≤ 4,000 chars, hard-truncated by Slack at 40,000. `markdown_text` (and the Block Kit `markdown` block the adapter uses): up to 12,000 chars. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | -| dm_support | Yes — 1:1 IM channels (channel IDs start with `D`). | [message event](https://docs.slack.dev/reference/events/message/) | -| group_model | workspace → channels (public `C…` / private `G…`), plus IM (`D…`) and multi-party IM (`mpim`); threads inside channels. | [message event](https://docs.slack.dev/reference/events/message/) | -| group_sender_identity | Yes — stable per-user `user` (`U…`) ID on every message event; resolvable to display/real name via `users.info`. Bot senders carry `bot_id` (`B…`) instead. Not consent-gated within an authorized workspace. | [message event](https://docs.slack.dev/reference/events/message/) | -| send_model | push — no reply-window/token TTL. Any `chat.postMessage` targeting a channel the bot is in; threading is `thread_ts`, not a time-bounded reply token. | [chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage) | -| proactive_push | Yes (bot may post unsolicited to channels it's a member of). Rate: no more than **1 message/second/channel** (short bursts tolerated). `chat.postMessage` is a "Special Tier" method carrying both this per-channel limit and a workspace-wide limit. | [Rate limits](https://docs.slack.dev/apis/web-api/rate-limits/) | -| bot_to_bot | Yes — other bots' messages are delivered as `message` events with `subtype: "bot_message"` and a `bot_id`/`bot_profile`; the receiving app must opt to process them. | [bot_message event](https://docs.slack.dev/reference/events/message/bot_message/) | -| typing_indicator | Not used as a typing dot. Assistant mode instead surfaces an ephemeral status line via `assistant.threads.setStatus` ("Thinking…"). | [assistant.threads.setStatus](https://docs.slack.dev/reference/methods/assistant.threads.setStatus) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | `chat.postMessage` with Block Kit `markdown` blocks + `text`/mrkdwn fallback. Graceful degrade to text-only on `invalid_blocks`/`msg_blocks_too_long`. | slack.rs:442, slack.rs:1716 | -| message_split/chunking | implemented | `message_limit()` = 11,900 (Block Kit `markdown` cap 12k minus ~100 headroom, `MARKDOWN_BLOCK_LIMIT` at slack.rs:1668); router splits at that bound and each chunk is one `markdown` block via `format::split_message`. Known gap: `split_message` isn't table-aware, so a single table > 11,900 splits mid-table into raw pipes (continuation blocks lack the header/separator rows). | slack.rs:433, slack.rs:1702 | -| streaming | implemented | Native streaming via `chat.startStream`/`appendStream`/`stopStream` when `streaming && assistant_mode && !other_bot_present` (`uses_native_streaming`); otherwise degrades to a post+edit placeholder; `streaming=false` = send-once. `stopStream` closes content-free (append semantics would duplicate — #1055), then `chat.update` writes the clean finalized Block Kit copy. | slack.rs:555, slack.rs:637 | -| reply/quote | partial | No `send_message_with_reply` override → uses the trait default (adapter.rs:336) = plain `send_message` (reply_to ignored). Slack threading is instead expressed via `thread_ts` on the channel ref. | adapter.rs:336, slack.rs:442 | -| edit_message | implemented | `chat.update` with the same block payload + text-only degrade on `invalid_blocks`/`msg_blocks_too_long`. Also the substrate for degraded-stream mid-edits and stream finalization. | slack.rs:527 | -| delete_message | workaround | No `delete_message` override → the trait default (adapter.rs:353) edits the message to a zero-width space (`\u{200b}`) via `edit_message` rather than calling `chat.delete`. Effectively blanks, doesn't remove. | adapter.rs:353 | -| emoji_reactions | implemented | `reactions.add`/`reactions.remove`; treats `already_reacted`/`no_reaction` as success (idempotent). Unicode→Slack shortname map (slack.rs:20) is limited to a default set; unmapped emoji fall back to `grey_question`. | slack.rs:489, slack.rs:508, slack.rs:20 | -| threads/topics | implemented | `create_thread` is a no-op mapping: returns a channel ref with `thread_id = trigger ts` (Slack threads are implicit, created by posting with `thread_ts`). No gateway `create_topic` (native adapter). | slack.rs:473 | -| media_inbound | implemented | Images (download+encode), text files (5-file / 1 MB total cap, `TEXT_FILE_COUNT_CAP`/`TEXT_TOTAL_CAP`, mirroring Discord #291), audio → STT. Private files fetched with the bot token; failed images trigger a `files:read`/format hint message. | slack.rs:1279, slack.rs:1376 | -| voice_stt | implemented | Audio attachments transcribed via `media::download_and_transcribe` when `stt.enabled`; transcript injected as a leading text block + best-effort echo (`stt::post_echo`). STT disabled → 🎤 reaction ack. | slack.rs:1292, slack.rs:1335 | -| trust_gate | partial | Native adapter does NOT use the shared `AdapterRouter::gate_incoming` (L3 identity trust) — that's wired only for gateway (gateway.rs:1198) and discord (discord.rs:1049). Slack enforces inline allowlists: `allowed_channels` (slack.rs:1219) and `allowed_users` (slack.rs:1224), plus `trusted_bot_ids` resolution (B…→U… via `bots.info`, slack.rs:308) for bot senders. | slack.rs:1219, slack.rs:308 | -| deny_echo | partial | On a denied user, no text reply — reacts 🚫 to the offending message (`add_reaction` at slack.rs:1236, inside the `allowed_users` deny branch at slack.rs:1224). This is the native adapter's own deny UX, not the gateway's `DenyIdentity` ID-echo path (gateway.rs:1201). | slack.rs:1236, slack.rs:1224 | -| mention_gating | implemented | Per `allow_user_messages`: `Mentions` requires `<@bot>`; `Involved` requires bot participation in the thread; `MultibotMentions` additionally requires an @mention once another bot is in the thread (slack.rs:1069). DMs are implicit mentions. `app_mention` handles the @-path (deduped against `message` at slack.rs:966). | slack.rs:1029, slack.rs:966 | -| slash_commands | not-implemented | `slash_commands` and `interactive` envelopes are ack'd and dropped: slash commands are blocked in thread composers and channel-level delivery lacks the `thread_ts` needed to route to a session. No in-text `/reset`/`/cancel` parsing in the native path either (that lives in the gateway path, gateway.rs:1004). | slack.rs:797 | -| multibot | implemented | Eager other-bot detection on inbound bot messages (`note_other_bot_in_thread`, slack.rs:134, invoked at slack.rs:901), persisted to a disk cache (irreversible). Disables streaming (`use_streaming`/`uses_native_streaming` gate on `other_bot_present`) and, with `MultibotMentions`, requires @mention. Consecutive-bot-turn cap (`MAX_CONSECUTIVE_BOT_TURNS` = 1000, enforced at slack.rs:980) + `BotTurnTracker` soft/hard limits guard loops. | slack.rs:134, slack.rs:901, slack.rs:980 | -| group_routing | implemented | Routed through `Dispatcher`; key is grouping-dependent — `slack:` in `Thread` mode or `slack::` in `Lane` mode (`Dispatcher::key`, dispatch.rs:295), with `thread_id` falling back to `channel_id` outside a thread (slack.rs:1524). Sender context is serialized with the Slack-native `thread_ts` key (slack.rs:1491) so agents calling the API directly see the right field. | dispatch.rs:295, slack.rs:1524, slack.rs:1491 | - -## 3. Platform quirks (`platform-quirks`) - -### Socket Mode keepalive (deaf-socket guard) -Slack's inbound WebSocket can go half-open (NAT idle-timeout silently drops inbound frames with no Close/FIN), leaving `read.next()` blocked forever so the reconnect loop never fires — the bot appears connected but goes deaf. The adapter pings every 30s (`PING_INTERVAL_SECS`) and force-reconnects if no inbound frame — including Slack's own pings — arrives within 75s (`IDLE_TIMEOUT_SECS`). Backoff doubles to a 30s cap, mirroring the gateway: 1,2,4,8,16,30,30… (slack.rs:699, slack.rs:711). - -### Native streaming duplication trap (#1055) -`chat.stopStream`'s `markdown_text` **appends**, it does not replace. Passing the full reply at finish would duplicate the entire message. The adapter closes the stream content-free, then `chat.update`s the finalized Block Kit copy. On the active path a failed final `chat.update` must NOT fall back to `postMessage` (would post a duplicate); on the degraded (post+edit) path it must, since no streamed content exists (slack.rs:637). - -### Block Kit markdown vs legacy mrkdwn -Messages are sent as Block Kit `markdown` blocks (12k cap; real headings/tables/code fences), with a `markdown_to_mrkdwn` `text` fallback for notifications/a11y. `message_limit` is bumped 4000→11,900 to keep typical Markdown tables in one block; `renders_native_tables()=true` tells the router to skip the `convert_tables` pre-pass. Workspaces that reject the block (`invalid_blocks`/`msg_blocks_too_long`) get an automatic text-only retry (`is_block_payload_rejected` matches the trailing error code exactly, so `invalid_blocks_field` does not falsely trigger) (slack.rs:1685, slack.rs:451). - -### app_mention vs message dedup -Both `app_mention` and `message` events can fire for one @mention. The adapter routes the @-path through `app_mention` and skips mention-bearing `message` events (except in DMs, where `app_mention` doesn't fire — slack.rs:968). Eager multibot detection (slack.rs:897) and bot-turn tracking (slack.rs:907) run BEFORE the self/bot gates (own-message skip at slack.rs:964) so own and filtered-out bot messages still count and are still detected (mirrors Discord #481/#483). - -### Positive-only, fail-closed caches -Participation and multibot are irreversible states, so their caches store positive results only; multibot is also disk-persisted (`multibot_cache`) to survive restarts. Thread-history checks (`bot_participated_in_thread`, and the `AllowBots::All` consecutive-bot loop cap) fail **closed** — an API error rejects the message rather than risk an unauthorized/looping response (slack.rs:327, slack.rs:1006). - -### Native trust divergence -Unlike the gateway/Discord paths (which call `AdapterRouter::gate_incoming`), the Slack adapter predates the unified trust gate and enforces access with inline `allowed_channels`/`allowed_users`/`trusted_bot_ids` checks. Deny UX is a 🚫 reaction, not the gateway's `DenyIdentity` identity-echo. A future consolidation onto `AdapterRouter::with_trust` (adapter.rs:485) would unify this (slack.rs:1219). - -### Findings log -- 2026-07-04 (A) `files.upload` sunsets 2025-11-12, replaced by `files.getUploadURLExternal`+`files.completeUploadExternal`; the 1 GB/file and 5 GB/free-workspace caps are separate, longstanding limits (the 5 GB cap dates to the 2019-03 changelog). [https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/] -- 2026-07-04 (A) Socket Mode is pre-authenticated by the `xapp-` app-level token (sent in the `Authorization` header to `apps.connections.open`); inbound events need no per-event HMAC/signature validation — the docs state this explicitly, unlike the HTTP Events API. [https://docs.slack.dev/apis/events-api/using-socket-mode] -- 2026-07-04 (A) Developer slash commands cannot be invoked in message threads and are delivered over HTTP only — matching the adapter's decision to ack-and-drop `slash_commands` envelopes. [https://docs.slack.dev/interactivity/implementing-slash-commands] -- 2026-07-04 (A) `chat.update` fails with `cant_update_message` on non-own messages and `edit_window_closed` under workspace edit settings (Tier 3); `chat.delete` with a bot token deletes only that bot's own messages — which is why OpenAB's delete uses a zero-width-space edit fallback rather than assuming cross-author delete. [https://docs.slack.dev/reference/methods/chat.delete] -- 2026-07-04 (A) `chat.postMessage` limits: `text` recommended ≤4,000 / truncated at 40,000; `markdown_text` (and the Block Kit `markdown` block) up to 12,000 — the basis for the 11,900 `message_limit`. [https://docs.slack.dev/reference/methods/chat.postMessage] -- 2026-07-04 (A) Other bots' messages ARE delivered as `message` events with `subtype: bot_message` + `bot_id`; OpenAB gates them via `allow_bot_messages` and `trusted_bot_ids`. [https://docs.slack.dev/reference/events/message/bot_message/] -- 2026-07-04 (A) Proactive posting rate = no more than 1 message/second/channel (bursts tolerated; `chat.postMessage` is a Special Tier method); relevant to multibot loop caps. [https://docs.slack.dev/apis/web-api/rate-limits/] -- 2026-07-04 (B) `chat.stopStream` appends rather than replaces — passing full content at finish duplicated the whole reply; fixed by close-then-`chat.update`. [openabdev/openab#1055] diff --git a/docs/platforms/teams.md b/docs/platforms/teams.md deleted file mode 100644 index 2ca2eb59b..000000000 --- a/docs/platforms/teams.md +++ /dev/null @@ -1,82 +0,0 @@ -# MS Teams — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the MS Teams adapter. For operator setup see `docs/teams.md`. Follows the schemas in [`README.md`](./README.md). - -Teams is reached via the **Bot Framework / Azure Bot Connector**, not a direct Teams API. The OpenAB Teams adapter lives in the gateway (`crates/openab-gateway/src/adapters/teams.rs`) and speaks the Bot Framework REST activity protocol; core treats it as a generic gateway platform through `GatewayAdapter` (`crates/openab-core/src/gateway.rs`). The gateway's per-platform reply dispatch is wired in `crates/openab-gateway/src/lib.rs` (`teams::handle_reply` at lib.rs:616). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | webhook — Bot Framework POSTs an `Activity` JSON to the bot's `/api/messages` messaging endpoint (one endpoint only). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| inbound_auth | 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. | [connector auth](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication?view=azure-bot-service-4.0) | -| threads | 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. | [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | -| slash_commands | 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. | [command menu](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) | -| mentions | `@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`. | [channel conversations · work with mentions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | -| emoji_reactions | Bot **receives** reaction events: yes, via `messageReaction` activities (`reactionsAdded`/`reactionsRemoved`). Bot **add/remove** own reactions: yes (SDK reaction APIs / connector). | [message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions) | -| edit_message | Yes — bot can update its own already-sent message: `PUT /v3/conversations/{conversationId}/activities/{activityId}`. Requires caching the activityId returned by the original post. | [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) | -| delete_message | Own messages only: `DELETE /v3/conversations/{conversationId}/activities/{activityId}`. A bot **cannot** update or delete messages sent by users. | [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) | -| rich_content | 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. | [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) · [suggested actions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| attachments | 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. | [build conversational · pictures](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| message_length_limit | ~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`. No fixed character count — a byte/UTF-16 budget, not a char cap. | [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) | -| dm_support | Yes — 1:1 personal chat (`conversationType: personal`). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| group_model | Taxonomy: `personal` (1:1), `groupChat` (group chat), `channel` (team channel, has reply chains + `channelData.team`/`channel`). Bot install scopes: `personal`, `groupChat`/`groupchat`, `team`. | [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | -| group_sender_identity | Yes — `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. | [channel conversations JSON payload](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) | -| send_model | 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`). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| proactive_push | Yes, 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. | [rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) | -| bot_to_bot | No — Teams does not deliver other bots' messages to a bot; bots respond to user activities only (@mention-gated in groups/channels). | [conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) | -| typing_indicator | Supported — bot can send a `typing` activity via the connector. Not currently emitted by the OpenAB adapter. | [connector API (typing activity)](https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-api-reference?view=azure-bot-service-4.0) | - -## 2. OpenAB feature support (`openab-feature-support`) - -Teams uses the shared `GatewayAdapter` (a `ChatAdapter` impl) in core (`crates/openab-core/src/gateway.rs`); the actual platform I/O is the gateway-side `teams::handle_reply` → `send_activity` (`crates/openab-gateway/src/adapters/teams.rs`), dispatched from `crates/openab-gateway/src/lib.rs:616`. Several core-issued commands (`create_topic`, `edit_message`, `delete_message`, reactions) are **not dispatched** by the Teams gateway `handle_reply` — only reactions are explicitly short-circuited; the rest fall through to `send_activity` and would be mis-sent as plain messages. - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | Core `GatewayAdapter::send_message` → `send_gateway_reply` → gateway `handle_reply` → `send_activity` POSTs a `message` activity with `textFormat: markdown`. | gateway.rs:231; teams.rs:562, `send_activity` def 329 | -| message_split/chunking | partial | 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. | adapter.rs:149; gateway.rs:473-474 | -| streaming | workaround | 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` (see below), so streaming edits don't actually reach Teams — effectively send-once. `update_activity` (PUT) exists in the adapter but is unwired dead code. | gateway.rs:701; teams.rs:372 (unused), 562 | -| reply/quote | not-implemented | `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. | gateway.rs:481-488, 249-263; teams.rs:591-604 | -| edit_message | not-implemented | 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. | gateway.rs:35, 585; teams.rs:562 | -| delete_message | not-implemented | Same as edit: 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. | gateway.rs:679; teams.rs:562 | -| emoji_reactions | not-implemented | `handle_reply` explicitly early-returns (silently ignores) `add_reaction`/`remove_reaction`. Platform supports bot reactions, but OpenAB does not send them for Teams. | gateway.rs:585; teams.rs:570-574 | -| threads/topics | not-implemented | 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. | gateway.rs:490; teams.rs:562 | -| media_inbound | not-implemented | 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`. | teams.rs:15-27, 488 | -| voice_stt | n/a | No voice-note ingestion path in the adapter; not applicable. | teams.rs:488 | -| trust_gate | implemented | 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`. | teams.rs:319-326, 482; gateway.rs:1198 | -| deny_echo | implemented | 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. | gateway.rs:1201-1226 | -| mention_gating | partial | 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. | gateway.rs:56-83; teams.rs:498-502, 537 | -| slash_commands | implemented | `/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. | gateway.rs:1004-1023 (WS), 1376-1389 (unified) | -| multibot | partial | 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. | gateway.rs:701; adapter.rs:415-421 | -| group_routing | implemented | 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. | teams.rs:493-544, 576-589; lib.rs:481-486 | - -## 3. Platform quirks (`platform-quirks`) - -### serviceUrl is per-conversation and must be cached/refreshed -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 (teams.rs:541-544) and refreshes the timestamp on every reply (teams.rs:581) to avoid TTL expiry mid-conversation; a background task in `lib.rs:481-486` evicts stale entries (4 h TTL). If an inbound activity lacks `serviceUrl`, the event is dropped (can't route replies; teams.rs:517-520). - -### Sender identity: `from.id` vs `aadObjectId` -Verified: the adapter uses **`activity.from.id`** (the `29:1abc...` Bot Framework/Teams user id) as `SenderInfo.id` (teams.rs:504-508), **not** `from.aadObjectId`. `aadObjectId` (Entra object id, teams.rs:35) 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` (teams.rs:61-79) because Teams places it differently for personal vs channel webhooks (tests at teams.rs:740-777 pin this). - -### The reply/quote target is dropped -`GatewayAdapter::send_message_with_reply` carries the visual-quote target in `quote_message_id` (gateway.rs:263, set from `reply_to_message_id`), but the Teams adapter only reads `reply.reply_to` (the origin/triggering-event id, gateway.rs:249) and maps it to `replyToId` (teams.rs:591-604). 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 below. - -### Auth is heavier than most adapters (endorsements + serviceUrl claim) -JWT validation goes beyond signature/aud/iss/exp: it also enforces (B2) that the signing JWK **endorses** the activity's `channelId` (teams.rs:277-288) and (B1) that the token's `serviceurl` claim equals the activity's `serviceUrl` (teams.rs:303-313) — binding the token to the specific channel/service origin. JWKS keys are cached (1 h TTL, teams.rs:158) with a force-refresh-on-cache-miss path for Microsoft key rotation (teams.rs:240-271). 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 (teams.rs:424-467). - -### Write-side commands are largely unimplemented -`edit_message`, `delete_message`, `create_topic`, and reactions are all issued by core but the Teams `handle_reply` only special-cases reactions (drop, teams.rs:570-574) — everything non-reaction is treated as a plain send (teams.rs:597-608). 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 at teams.rs:372). This is the main gap for a future PR. - -### Findings log -- 2026-07-04 (A) 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`. [format bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/format-your-bot-messages) -- 2026-07-04 (A) 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. [rate limits](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/rate-limit) -- 2026-07-04 (A) `suggestedActions` support `imBack` only, ≤6 buttons, one-on-one chats only, and not alongside attachments (any conversation type). [suggested actions](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) -- 2026-07-04 (A) Outbound pictures ≤1024×1024 px, ≤1 MB, PNG/JPEG/GIF; animated GIF unsupported; Markdown inline image defaults to 256×256. [build conversational](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/build-conversational-capability) -- 2026-07-04 (A) Bots can add/remove reactions and receive `messageReaction` (`reactionsAdded`/`reactionsRemoved`) events; OpenAB uses neither for Teams. [message reactions](https://learn.microsoft.com/en-us/microsoftteams/platform/teams-sdk/in-depth-guides/message-reactions) -- 2026-07-04 (A) Bot can edit (`PUT .../activities/{activityId}`) and delete (`DELETE .../activities/{activityId}`) its own messages but never user messages. [update/delete bot messages](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/update-and-delete-bot-messages) -- 2026-07-04 (A) No native bot slash-command protocol; command menus arrive as plain `message` activities and must be text-parsed. [command menu](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/create-a-bot-commands-menu) -- 2026-07-04 (A) 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. [channel conversations](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations) -- 2026-07-04 (B) Verified sender id = `activity.from.id` (`29:...`), not `aadObjectId`; adapter (teams.rs:504-508) forwards `from.id` as trust-gate key. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) -- 2026-07-04 (B) 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. [teams.rs](crates/openab-gateway/src/adapters/teams.rs) diff --git a/docs/platforms/telegram.md b/docs/platforms/telegram.md deleted file mode 100644 index 9771b21a5..000000000 --- a/docs/platforms/telegram.md +++ /dev/null @@ -1,84 +0,0 @@ -# Telegram — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the Telegram adapter. For operator setup see `docs/telegram.md`. Follows the schemas in [`README.md`](./README.md). - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| transport | webhook (JSON POST of `Update`); long-poll `getUpdates` also exists but OpenAB uses webhook. | [Bot API — getting updates](https://core.telegram.org/bots/api#getting-updates) | -| inbound_auth | L1 only: `X-Telegram-Bot-Api-Secret-Token` header (opaque shared secret set on `setWebhook`) + optional source-IP allowlist (`149.154.160.0/20`, `91.108.4.0/22`). No HMAC/body signature. | [Webhooks](https://core.telegram.org/bots/webhooks), [setWebhook](https://core.telegram.org/bots/api#setwebhook) | -| threads | native (forum topics only): supergroups with topics enabled carry `message_thread_id`; created via `createForumTopic`. Non-forum chats have no threading (reply-to quoting exists separately). | [createForumTopic](https://core.telegram.org/bots/api#createforumtopic) | -| slash_commands | supported: `/command` text arrives as a `bot_command` entity; discoverable list registered via `setMyCommands`. Command names ≤32 chars, Latin letters/digits/underscores. Delivery is just message text — the bot parses it. | [setMyCommands](https://core.telegram.org/bots/api#setmycommands), [commands](https://core.telegram.org/bots/features#commands) | -| mentions | two entity types: `mention` (`@username`) and `text_mention` (users without a username, carries a `User`). With privacy mode ON, a bot in a group only receives: commands meant for it (`/cmd@bot`), general commands if it was the last bot to message, replies to its messages, inline messages via it, and all service messages. | [MessageEntity](https://core.telegram.org/bots/api#messageentity), [privacy mode](https://core.telegram.org/bots/features#privacy-mode) | -| emoji_reactions | bot **add/set**: yes via `setMessageReaction` (replaces the bot's whole reaction set; non-premium is limited to one emoji from the allowed set). **remove**: yes (set to empty). **receive**: `message_reaction` updates exist but — quoting the API — "The bot must be an administrator in the chat and must explicitly specify `message_reaction` in the list of `allowed_updates` to receive these updates. The update isn't received for reactions set by bots." | [setMessageReaction](https://core.telegram.org/bots/api#setmessagereaction), [Update.message_reaction](https://core.telegram.org/bots/api#update) | -| edit_message | yes: `editMessageText` (also `editMessageCaption`/`editMessageMedia`) on the bot's own messages. | [editMessageText](https://core.telegram.org/bots/api#editmessagetext) | -| delete_message | `deleteMessage`: a bot can delete its own outgoing messages; can delete others' only with admin `can_delete_messages`. Constraint: a message can only be deleted if it was sent less than **48 hours** ago (with narrow exceptions, e.g. service messages / a bot's own messages / channel/anonymous-admin cases). | [deleteMessage](https://core.telegram.org/bots/api#deletemessage) | -| rich_content | Markdown/MarkdownV2 + HTML `parse_mode`; inline keyboards / reply keyboards / callback buttons. **Bot API 10.1 (2026-06-11)** additionally added `sendRichMessage` + `InputRichMessage` for GFM-style rich blocks (tables, headings, syntax-highlighted code) — see quirks. No legacy "card" object beyond keyboards. | [formatting](https://core.telegram.org/bots/api#formatting-options), [api-changelog (10.1)](https://core.telegram.org/bots/api-changelog) | -| attachments | inbound: photo, document, voice, audio, video, etc. outbound: photo/document/etc. **Download** via `getFile` is capped at **20 MB** on the cloud Bot API (up to 2 GB only with a self-hosted local Bot API server). Send caps: photos ~10 MB, other files 50 MB. | [getFile](https://core.telegram.org/bots/api#getfile), [sendDocument](https://core.telegram.org/bots/api#senddocument) | -| message_length_limit | `sendMessage` text: **1–4096 UTF-8 chars**; captions 1024. Longer plain text must be chunked. (Rich messages via `sendRichMessage` accept larger content — see quirks; exact ceiling unverified from truncated docs.) | [sendMessage](https://core.telegram.org/bots/api#sendmessage) | -| dm_support | yes (1:1 private chat); the user must `/start` the bot first — bots cannot initiate a private chat. | [Bot FAQ](https://core.telegram.org/bots/faq) | -| group_model | private (DM), group, supergroup (optionally forum = topics), channel. `chat.type` distinguishes them. | [Chat](https://core.telegram.org/bots/api#chat) | -| group_sender_identity | yes: stable numeric `from.id` present on group messages (not consent-gated), unless sent on behalf of a chat (`sender_chat`). Privacy mode limits *which* messages the bot sees, not the identity of the ones it does. | [Message](https://core.telegram.org/bots/api#message) | -| send_model | push model: the bot may send to any chat it shares with the user at any time (no reply-window/TTL). Reply/quote via `reply_parameters`. Rate-limited (see proactive_push). | [sendMessage](https://core.telegram.org/bots/api#sendmessage) | -| proactive_push | allowed to any user who has started the bot / any group it's in. Rate limits (per FAQ): "avoid sending more than one message per second" per chat; bulk broadcast "not able to broadcast more than about 30 messages per second" overall; "not be able to send more than 20 messages per minute" in a group. No reply-window gating. | [Broadcasting FAQ](https://core.telegram.org/bots/faq) | -| bot_to_bot | per FAQ, "Bots will not be able to see messages from other bots regardless of mode." `is_bot` is present on `from` for the rare cases forwarded/quoted. | [Bot FAQ](https://core.telegram.org/bots/faq) | -| typing_indicator | yes: `sendChatAction` (e.g. `typing`), auto-clears after ~5s or on next message. Not used by the OpenAB adapter. | [sendChatAction](https://core.telegram.org/bots/api#sendchataction) | - -## 2. OpenAB feature support (`openab-feature-support`) - -Telegram runs through the **gateway** path (webhook → `GatewayEvent`; `GatewayReply` → `handle_reply` command dispatch), not a `ChatAdapter` trait impl. So the `ChatAdapter` default-impl semantics (edit/delete/stream) do not directly apply; the gateway reply handler dispatches by `reply.command`. Note: the `MAX_DOWNLOAD` constants used below live in the **gateway** crate (`crates/openab-gateway/src/media.rs:13-15` → IMAGE 10 MB, FILE 20 MB, AUDIO 20 MB), while STT lives in **core**. - -| Feature | Status | Note | Ref | -|---|---|---|---| -| send_message | implemented | `sendMessage` with `parse_mode: Markdown`; on a Markdown parse error (`is_markdown_parse_error`) retries once as plain text. | `crates/openab-gateway/src/adapters/telegram.rs:604`, `:621` | -| message_split/chunking | implemented | `chunk_text` splits at 4096 chars, preferring newline boundaries; hard-splits a single over-limit line char-by-char. (Adapter-local, not the trait's `split_delivery`.) | `crates/openab-gateway/src/adapters/telegram.rs:602`, `:237` | -| streaming | partial | No legacy streaming API; streaming maps onto `editMessageText` when `reply_to` is a real message_id. When `reply_to == "draft"` and `rich_messages` is on, it calls `sendRichMessageDraft` (skips updates <30 chars, truncates at 32768 via `floor_char_boundary`). That draft fn `send_rich_message_draft` is `#[allow(dead_code)]` — "Wired but unused until gateway streaming infrastructure integrates" — so the draft path is not yet driven by gateway streaming. | `crates/openab-gateway/src/adapters/telegram.rs:470`, `:475`, `:485`, `:379` | -| reply/quote | partial | `GatewayReply` carries a `quote_message_id`, but the Telegram send path does **not** attach `reply_to_message_id`/`reply_parameters`; outbound only sets `message_thread_id` (forum-topic routing). So agent quote-replies aren't rendered as native quotes. | `crates/openab-gateway/src/adapters/telegram.rs:610`; `crates/openab-core/src/gateway.rs:516` | -| edit_message | implemented | `command == "edit_message"` → `editMessageText` (real msg_id) or, for a `"draft"` ref with `rich_messages` on, `sendRichMessageDraft`. | `crates/openab-gateway/src/adapters/telegram.rs:470`, `:491` | -| delete_message | not-implemented | No `deleteMessage` call anywhere in the adapter and no delete command handled in `handle_reply` (the command dispatch chain covers only create_topic / edit_message / reactions / send). Telegram supports it natively; simply not wired. The trait's default `delete_message` returns an unsupported error. | `crates/openab-gateway/src/adapters/telegram.rs:405`; `crates/openab-core/src/adapter.rs:353` | -| emoji_reactions | workaround | `add_reaction`/`remove_reaction` → `setMessageReaction`. Because non-premium chats allow only ONE reaction, the adapter keeps a local `reaction_state` map and always sends the full replacing set; the mood-face emojis (😊😎🫡🤓😏✌️💪🦾) are dropped so the terminal 👍 "done" marker isn't clobbered, and 🆗→👍 is remapped. | `crates/openab-gateway/src/adapters/telegram.rs:506`, `:511`, `:516`, `:527` | -| threads/topics | implemented | `command == "create_topic"` → `createForumTopic`, returns `message_thread_id` in a `GatewayResponse`. The trait's `create_thread` issues that command with a 5s timeout and falls back to the same channel on failure/timeout; core ingress only creates a topic for supergroups with no existing `thread_id`. | `crates/openab-gateway/src/adapters/telegram.rs:414`, `:427`; `crates/openab-core/src/gateway.rs:490`, `:530`, `:1036` | -| media_inbound | implemented | photo (largest by `width*height`), document (text-only, extension-checked + UTF-8 validated), voice, audio downloaded via `getFile`; size-gated against `IMAGE/AUDIO/FILE_MAX_DOWNLOAD` (both Content-Length pre-check and post-read); images resized/compressed; non-text or binary docs rejected with a reason. | `crates/openab-gateway/src/adapters/telegram.rs:160`, `:658`, `:802`, `:809`, `:898` | -| voice_stt | partial | The adapter only downloads voice/audio as an `audio` attachment (`MediaKind::Audio`, `download_telegram_media`); it does no transcription. STT happens downstream in core: `gateway.rs:955` (batched) / `:1340` (per-message) call `download_and_transcribe` when `stt_config.enabled`, injecting a `[Voice message transcript]:` block. | `crates/openab-gateway/src/adapters/telegram.rs:170`; `crates/openab-core/src/gateway.rs:955`, `:1340`; `crates/openab-core/src/media.rs:302` | -| trust_gate | partial | Adapter enforces L1 only: `secret_token` header + optional Telegram source-subnet check (`telegram_trusted_source_only`; IP extraction is phase-1 "observe", trusting CF/X-Real-IP then the spoofable leftmost XFF). L2 scope / L3 identity allowlists are enforced by the shared core ingress trust gate, not the adapter. | `crates/openab-gateway/src/adapters/telegram.rs:109`, `:128`, `:133`, `:271`; `crates/openab-core/src/gateway.rs:1190` | -| deny_echo | implemented | Not in the adapter — handled by core ingress: an L3 `DenyIdentity` decision echoes the sender their ID ("Your ID: … Ask the admin to add it to allowed_users"), throttled to one echo per (platform, sender) per `ECHO_WINDOW` (300s). `DenyScope` is a silent drop. | `crates/openab-core/src/gateway.rs:1201`, `:1220`, `:1150`, `:1228` | -| mention_gating | implemented | In groups/supergroups without a thread, core `should_skip_event` requires an `@bot_username` mention; in-thread events bypass the gate. The adapter extracts `mention` entities into `event.mentions`. | `crates/openab-core/src/gateway.rs:72`, `:76`; `crates/openab-gateway/src/adapters/telegram.rs:195` | -| slash_commands | implemented | `/reset` and `/cancel` handled in core gateway (both the batched consumer path and the per-message path). The adapter has no per-command parsing beyond forwarding text. | `crates/openab-core/src/gateway.rs:1004`, `:1017`, `:1376`, `:1389` | -| multibot | implemented | `should_skip_event` drops other bots' events unless the sender id is in `trusted_bot_ids`; the adapter forwards `is_bot` from `from`. (Telegram rarely delivers other bots' messages anyway.) | `crates/openab-core/src/gateway.rs:58`; `crates/openab-gateway/src/adapters/telegram.rs:217` | -| group_routing | implemented | Session keyed by `chat.id` + optional `message_thread_id`; `compute_draft_id` derives a stable per-(chat,thread) draft id to avoid forum-topic collisions. | `crates/openab-gateway/src/adapters/telegram.rs:206`, `:339`, `:484` | - -## 3. Platform quirks (`platform-quirks`) - -### "Rich Message" API is real (Bot API 10.1) — but the adapter path is flag-gated and partly dead-code -The adapter's `sendRichMessage` / `sendRichMessageDraft` / `InputRichMessage.markdown` calls correspond to methods **genuinely added in Bot API 10.1 (2026-06-11)** ([api-changelog](https://core.telegram.org/bots/api-changelog)): "Added the method `sendRichMessage`…", "Added the method `sendRichMessageDraft`, allowing bots to stream partial rich messages", "Added the class `InputRichMessage`…". This corrects an earlier assumption that the path was fictional. Caveats that remain code-side, not API-side: -- The path is gated behind the adapter's `rich_messages` flag and falls back to legacy chunked `sendMessage` on **any** error (`is_complex_markdown` picks headings/GFM tables/over-4096-char text as candidates; code blocks are deliberately routed to legacy `sendMessage` to keep syntax highlighting). -- `send_rich_message_draft` is `#[allow(dead_code)]` and not yet driven by gateway streaming. -- The **exact 32768-char limit** and the precise `InputRichMessage` field names (`markdown` vs `html`) the adapter assumes could not be confirmed from the official page (method-body sections were truncated on fetch); anyone enabling this should confirm those against their Bot API server / the full 10.1 docs before relying on the 32768 truncation and the html/markdown branch. - -### Reactions are single-slot, non-additive -Telegram non-premium chats allow only one reaction per message. The adapter therefore maintains `reaction_state` and always PUTs the full replacing set via `setMessageReaction`; multi-emoji "mood" reactions used on Discord are deliberately dropped so the terminal 👍 isn't clobbered. Consequence: OpenAB's add/remove reaction semantics are emulated, not literal. Separately, inbound reaction events (`message_reaction`) require the bot be a chat admin + opt in via `allowed_updates`, and are **never** delivered for reactions set by bots — the adapter does not subscribe to them. - -### Auth is L1-only and IP extraction is phase-1 -Only `secret_token` + an optional source-subnet check gate the webhook. Subnet enforcement is opt-in (`telegram_trusted_source_only`), and IP extraction trusts `CF-Connecting-IP` / `X-Real-IP` then falls back to the spoofable leftmost `X-Forwarded-For` — a documented phase-2 gap. Real identity/scope trust lives in core ingress (L2 scope / L3 identity), and the deny-echo lives there too. - -### 20 MB inbound download ceiling -`getFile` on the cloud Bot API can't serve files >20 MB; large media will fail to download regardless of OpenAB's own `*_MAX_DOWNLOAD` limits (10/20/20 MB). A self-hosted local Bot API server is required for up to 2 GB. Note also that STT downstream imposes its own 25 MB (Whisper) cap in core (`media.rs:311`). - -### Outbound quotes aren't native -`GatewayReply.quote_message_id` exists, but the Telegram send path never sets `reply_to_message_id`/`reply_parameters` — it only sets `message_thread_id`. Agent quote-replies therefore land in the right topic but aren't rendered as native Telegram quotes. - -### Findings log -- 2026-07-04 (A) Bot API 10.1 (2026-06-11) added `sendRichMessage`, `sendRichMessageDraft`, and `InputRichMessage` — the adapter's "rich" path targets a REAL API, not a fictional one (corrects prior draft). Exact 32768 limit / field names still unverified (docs truncated on fetch). [[api-changelog](https://core.telegram.org/bots/api-changelog)] -- 2026-07-04 (A) Telegram cloud `getFile` caps downloads at 20 MB; up to 2 GB needs a self-hosted local Bot API server. [[getFile](https://core.telegram.org/bots/api#getfile)] -- 2026-07-04 (A) `deleteMessage` generally only works within 48h for a bot's outgoing messages; deleting others' needs admin `can_delete_messages`. [[deleteMessage](https://core.telegram.org/bots/api#deletemessage)] -- 2026-07-04 (A) `message_reaction` updates require the bot be a chat admin + opt-in via `allowed_updates`, and "The update isn't received for reactions set by bots." [[Update](https://core.telegram.org/bots/api#update)] -- 2026-07-04 (A) `sendMessage` text limit is 1–4096 UTF-8 chars; adapter chunks at 4096. [[sendMessage](https://core.telegram.org/bots/api#sendmessage)] -- 2026-07-04 (A) Broadcasting limits: ~1 msg/s per chat, ~30 msg/s overall bulk, 20 msg/min per group. [[FAQ](https://core.telegram.org/bots/faq)] -- 2026-07-04 (A) With privacy mode ON, a group bot receives only `/cmd@bot` commands, general commands if it messaged last, replies to it, inline messages via it, and all service messages. [[privacy mode](https://core.telegram.org/bots/features#privacy-mode)] -- 2026-07-04 (B) `delete_message` is unimplemented in the Telegram adapter despite native support — no `deleteMessage` call or command handler; trait default returns unsupported. [`crates/openab-gateway/src/adapters/telegram.rs:405`; `crates/openab-core/src/adapter.rs:353`] -- 2026-07-04 (B) Outbound send sets `message_thread_id` for topic routing but never `reply_to_message_id`, so agent quote-replies aren't native quotes. [`crates/openab-gateway/src/adapters/telegram.rs:610`] -- 2026-07-04 (B) MAX_DOWNLOAD constants (IMAGE 10 / FILE 20 / AUDIO 20 MB) live in the gateway crate, not core; core adds a separate 25 MB Whisper STT cap. [`crates/openab-gateway/src/media.rs:13-15`; `crates/openab-core/src/media.rs:311`] -- 2026-07-04 (B) Voice/audio STT is not done in the adapter; core `gateway.rs:955`/`:1340` transcribe `audio` attachments when `stt_config.enabled`. [`crates/openab-core/src/gateway.rs:955`, `:1340`] diff --git a/docs/platforms/wecom.md b/docs/platforms/wecom.md deleted file mode 100644 index 8363c552c..000000000 --- a/docs/platforms/wecom.md +++ /dev/null @@ -1,83 +0,0 @@ -# WeCom (企業微信) — platform notes - -**Schema version:** 2026-07-07 - -Engineering-facing capability & quirks reference for the WeCom adapter. For operator setup see `docs/wecom.md`. Follows the schemas in [`README.md`](./README.md). - -WeCom is integrated via the **self-built app (自建应用 / agentid) callback model**, not the newer "智能机器人 / 群机器人" model. The adapter (`crates/openab-gateway/src/adapters/wecom.rs`) receives a user's 1:1 message via an AES-encrypted callback and replies proactively via `/cgi-bin/message/send`. - -## 1. Platform capability (`platform-capability`) - -| Field | Value | Source | -|---|---|---| -| `transport` | webhook (HTTP callback; WeCom POSTs AES-encrypted XML to the configured callback URL) | [self-built app receive overview](https://developer.work.weixin.qq.com/document/path/90238) | -| `inbound_auth` | L1: `msg_signature` = SHA1 of `sort(token, timestamp, nonce, encrypt).concat()`; body is AES-256-CBC decrypted with the 43-char `EncodingAESKey` (base64→32 bytes), IV = first 16 key bytes, WeCom PKCS7 block_size=32; inner corp_id suffix validated (`decrypt_message`, `wecom.rs:99` sig, `wecom.rs:125` decrypt) | [receive / callback doc](https://developer.work.weixin.qq.com/document/path/90238) | -| `threads` | none — self-built app callback is a flat 1:1 conversation; no thread/topic primitive | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `slash_commands` | Not a platform primitive. No command registration/delivery; text like `/reset` arrives as ordinary `text` content and any interpretation is OpenAB-side | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `mentions` | n/a in the self-built app 1:1 model — every callback is a direct message from one `FromUserName`; no @mention concept. (Group @mention exists only under the separate 智能机器人 model, which this adapter does not use) | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `emoji_reactions` | No reaction API for self-built apps: bot cannot **add** or **remove** reactions, and reaction events are **not** delivered. The receive doc enumerates only six inbound callback types: text, image, voice, video, location, link (no reaction, no edit) | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `edit_message` | No edit API. The only mutation is **recall** (`/cgi-bin/message/recall`), which deletes rather than edits, and only within 24h on messages this app sent | [撤回应用消息](https://developer.work.weixin.qq.com/document/path/94867) | -| `delete_message` | Own messages only, via recall (`/cgi-bin/message/recall`), within **24 hours** of send; cannot delete users'/others' messages (data already delivered to the WeChat-plugin end also can't be pulled back) | [撤回应用消息](https://developer.work.weixin.qq.com/document/path/94867) | -| `rich_content` | Supported outbound msgtypes: text, image, voice, video, file, **textcard**, **news (图文)**, **mpnews**, **markdown**, miniprogram_notice, **template_card**. Adapter uses only `text` | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -| `attachments` | Outbound media upload caps (`media/upload`): image ≤10MB (JPG/PNG), voice ≤2MB/60s (AMR), video ≤10MB (MP4), general file ≤20MB; min 5 bytes; temp media_id valid 3 days. Inbound: image/voice/video callbacks carry a `MediaId` pulled via `media/get` (same 3-day validity). Note: the receive doc's six enumerated inbound types are text/image/voice/video/location/link — inbound *file* (`MediaId` + `FileName`) is not in that list yet is delivered in practice and handled by the adapter | [上传临时素材](https://developer.work.weixin.qq.com/document/path/90253) · [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `message_length_limit` | `text` content ≤ **2048 bytes** ("超过将截断" — server truncates; ~680 CJK chars at 3 bytes each). Chunking required for long replies | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -| `dm_support` | Yes — the self-built app model *is* 1:1 (app ↔ member via `touser`) | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -| `group_model` | Self-built app has no group callback. A separate `appchat` (群聊) send API and the 智能机器人 model exist but are not used by this adapter | [appchat/send](https://developer.work.weixin.qq.com/document/path/90248) | -| `group_sender_identity` | n/a for this adapter (1:1 only). In 1:1, the stable sender id is `FromUserName` (member UserID), always present, no consent gate | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `send_model` | **Push** — app calls `message/send` with `access_token` + `agentid` + `touser`. No reply-window / reply-token; a user need not have messaged first | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -| `proactive_push` | Allowed and unsolicited. Quotas: per app ≤ (account cap × 200) person-times/day; per app→member ≤ 30/min and 1000/hour (excess "会被丢弃不下发" — silently dropped) | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | -| `bot_to_bot` | n/a — self-built app callbacks originate from human members (`FromUserName` UserID); the platform does not deliver other apps'/bots' messages to a self-built app | [receive msg doc](https://developer.work.weixin.qq.com/document/path/90239) | -| `typing_indicator` | Not supported by the API | [message/send](https://developer.work.weixin.qq.com/document/path/90236) | - -## 2. OpenAB feature support (`openab-feature-support`) - -| Feature | Status | Note | Ref | -|---|---|---|---| -| `send_message` | implemented | `message/send` msgtype=text with `agentid` + `touser`; token cached & auto-refreshed, retries once on errcode 42001 | `wecom.rs:558` (`send_text`), `wecom.rs:584` (`post_with_token_retry`) | -| `message_split/chunking` | implemented | Byte-aware split at 2048 (WeCom's server-side byte cap); prefers `\n` boundaries, splits over-long lines at UTF-8 char boundaries (`char_indices`) so multibyte chars aren't severed. Uses local `split_text_lines`, not the trait `split_delivery` | `wecom.rs:517` (call), `wecom.rs:840` (`split_text_lines`); `adapter.rs:149` (trait `split_delivery`) | -| `streaming` | workaround | No edit API in callback mode, so "streaming" = optional "⏳..." placeholder + debounce-buffer chunks into a `watch` channel + recall placeholder + resend consolidated text (`flush_thinking`). Causes client flicker → **default OFF** (`WECOM_STREAMING_ENABLED`, `debounce_secs=3`). With streaming off, chunks buffer silently and one consolidated message is sent | `wecom.rs:38-47` (config), `wecom.rs:381-459` (buffer/spawn), `wecom.rs:773` (`flush_thinking`) | -| `reply/quote` | n/a | No reply/quote primitive in the self-built app model. The trait `send_message_with_reply` default just falls back to plain send; adapter never wires it | `adapter.rs:336` (trait default) | -| `edit_message` | workaround | `reply.command == "edit_message"` only pushes new text into an in-flight streaming `watch` channel (`handle_edit_message`); there is no real WeCom edit. Outside a pending stream it is a no-op. The trait `edit_message` default (returns "not supported") otherwise applies | `wecom.rs:357` (dispatch), `wecom.rs:546` (`handle_edit_message`); `adapter.rs:330` (trait default) | -| `delete_message` | not-implemented | Recall API exists (24h window) but adapter never calls `/cgi-bin/message/recall` for user-facing delete — only internally to remove the thinking placeholder. Trait `delete_message` default edits to zero-width space, which WeCom can't honor | `wecom.rs:786` (internal recall only); `adapter.rs:353` (trait default) | -| `emoji_reactions` | n/a | `reply.command` `add_reaction` / `remove_reaction` are explicitly matched and ignored with a log line — WeCom self-built apps have no reaction API | `wecom.rs:351-356` | -| `threads/topics` | n/a | `create_topic` command explicitly ignored (logged). No thread primitive on platform | `wecom.rs:351-356` | -| `media_inbound` | partial | Inbound `image` → download over HTTPS-only (SSRF guard), reject >10MB, resize ≤1200px + JPEG q75 (GIF passthrough). `file` → `media/get` (retry on 42001), reject >20MB, **text files only** (extension/filename allowlist) and must be valid UTF-8; binary/office files rejected. voice/video/location/link msgtypes are dropped (only text/image/file forwarded) | `wecom.rs:1103` (`download_wecom_image`), `wecom.rs:1249` (`fetch_media_with_retry`), `wecom.rs:1287` (`download_wecom_file`), `wecom.rs:1234` (`is_text_file`), `wecom.rs:1410` (`resize_and_compress`) | -| `voice_stt` | not-implemented | `voice` msgtype is not in the accepted set (`text\|image\|file`); voice callbacks are dropped, no STT | `wecom.rs:1022` | -| `trust_gate` | implemented | Shared. Gateway ingress gate `gate_incoming` (L2 scope + L3 identity) runs before dispatch; wecom events carry `sender.id = FromUserName` (UserID) and `channel.id = wecom:{corp_id}:{from_user}` for keying | `wecom.rs:1059-1076` (event build); `gateway.rs:1196-1198` (gate call); `adapter.rs:495` (`gate_incoming` def) | -| `deny_echo` | implemented | Shared. On `DenyIdentity` the gateway echoes the sender their UserID with a request-access hint (throttled via `echo_allowed`); `DenyScope` silently drops. Platform-agnostic path, applies to wecom replies | `gateway.rs:1201-1226` | -| `mention_gating` | n/a | Shared `@mention` gating only fires for group/supergroup channel_type; wecom events are `channel_type="direct"`, so gating is bypassed by design | `wecom.rs:1064`; `gateway.rs:72-80` | -| `slash_commands` | partial | Shared. No platform slash mechanism; commands arrive as plain text and are handled by OpenAB's generic command layer, not in the wecom adapter | `wecom.rs:1030-1035` | -| `multibot` | n/a | Self-built app 1:1 callbacks come only from human members (`is_bot: false`); no other-bot delivery, no multi-bot channel | `wecom.rs:1067-1072` | -| `group_routing` | partial | Sessions keyed by `wecom:{corp_id}:{from_user}` (per-user 1:1); no group routing since there are no group callbacks in this model | `wecom.rs:1059` | - -## 3. Platform quirks (`platform-quirks`) - -### Send / push model (no reply window) - -WeCom self-built apps are pure push: given a valid `access_token` + `agentid`, the app can message any member via `touser` at any time — there is no LINE-style reply token or reply window. `access_token` (7200s TTL) is cached with a 300s refresh margin (`TOKEN_REFRESH_MARGIN_SECS`, `wecom.rs:225`) and force-refreshed on errcode 42001 across both `message/send` and `media/get`. - -### Crypto / callback specifics - -- `EncodingAESKey` is 43 base64 chars **without** padding; adapter appends `=` and decodes with Indifferent padding + `allow_trailing_bits` (the 43rd char's last 2 bits are not payload). Result must be exactly 32 bytes (`decode_aes_key`, `wecom.rs:74`). -- WeCom uses **PKCS7 with block_size=32** (not 16); adapter decrypts AES-256-CBC with `NoPadding` and strips padding manually (pad value 1–32). Plaintext = `random(16) + msg_len(4 BE) + msg + corp_id`; inner corp_id must equal configured `CORP_ID` (`wecom.rs:149-182`). IV = first 16 key bytes (`wecom.rs:134`). -- Defense-in-depth: outer envelope `ToUserName` must equal `CORP_ID` (`wecom.rs:971`); `msg_signature` compared in constant time (`subtle::ConstantTimeEq`, `wecom.rs:122`); stale callbacks (>300s timestamp skew) rejected (`wecom.rs:947-953`); 30s TTL / 10k-entry dedupe cache on `MsgId` absorbs WeCom's ~5s retries (`wecom.rs:189-219`). -- `gettoken` requires `corpsecret` as a **query param** (protocol-mandated) — operators must redact query strings on `/cgi-bin/gettoken` in proxy logs; gateway never logs that URL (`wecom.rs:275-283`). - -### "Streaming" is recall + resend - -Because callback mode has no message-edit API, streaming is emulated: optional "⏳..." placeholder, debounce-buffer deltas into a `tokio::sync::watch` channel (default 3s), then recall the placeholder and send the consolidated final text via `flush_thinking`. This flickers, so it is **off by default** (`WECOM_STREAMING_ENABLED`). With it off, deltas are buffered silently and one message is sent when the debounce settles — no flicker, no recall. A 300s idle cap on the debounce task prevents an orphaned pending entry. - -### Inbound filtering is aggressive - -Only `text`, `image`, `file` msgtypes are forwarded (`wecom.rs:1022`); `voice` / `video` / `location` / `link` are dropped. Files must pass a text-extension/filename allowlist AND be valid UTF-8 — office/binary files are rejected (no doc parsing). Images are HTTPS-only, ≤10MB, downscaled to ≤1200px JPEG q75 (GIF passthrough). Placeholder prompts are injected for media: image → "Describe this image.", file → "User sent a file: {name}" (`wecom.rs:1030-1035`). - -### Findings log - -- 2026-07-04 (A) Self-built app receive doc enumerates **six** inbound types: text/image/voice/video/location/link — file is *not* listed there (yet delivered & handled by the adapter). `FromUserName` = member UserID, `MsgId` = 64-bit; no group/@mention/reaction/edit in this model. [https://developer.work.weixin.qq.com/document/path/90239] -- 2026-07-04 (A) `text` content capped at 2048 bytes (truncated); push quota per app→member 30/min, 1000/hr, per-app (account-cap×200)/day, excess silently dropped. msgtypes: text/image/voice/video/file/textcard/news/mpnews/markdown/miniprogram_notice/template_card. [https://developer.work.weixin.qq.com/document/path/90236] -- 2026-07-04 (A) No edit API; only recall (`/cgi-bin/message/recall`) within 24h on this app's own messages (delete, not edit; WeChat-plugin-end data can't be pulled back). [https://developer.work.weixin.qq.com/document/path/94867] -- 2026-07-04 (A) Media caps: image 10MB (JPG/PNG), voice 2MB/60s (AMR), video 10MB (MP4), file 20MB; min 5 bytes; temp media_id valid 3 days. [https://developer.work.weixin.qq.com/document/path/90253] -- 2026-07-04 (A) Callback auth = SHA1 msg_signature (sorted token/ts/nonce/encrypt) + AES-256-CBC via 43-char EncodingAESKey, PKCS7 block_size=32, IV = first 16 key bytes. [https://developer.work.weixin.qq.com/document/path/90238] -- 2026-07-04 (B) All section-2 `file:line` refs verified against the tree at `/tmp/openab-check`; corrected `send_text` / `fetch_media_with_retry` / `gate_incoming` line numbers and the mention-gating range. `WecomAdapter` is a standalone handler (does **not** impl `ChatAdapter`); trait defaults referenced are in `adapter.rs`. [PR #1295] - -*Sourcing: `(A)` intrinsic facts → official WeCom doc; `(B)` OpenAB decisions/findings → `file:line` (PR link `#1295`).* From 71f37947c3909bae80ba3b19da18426bc622eddb Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Wed, 8 Jul 2026 10:12:33 +0000 Subject: [PATCH 31/37] feat(platforms): add 4 optional capability fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote known platform quirks from prose notes to typed, comparable fields (all optional, so no forced backfill): - attachments.max_count — max attachments per message (Discord = 10) - attachments.outbound_delivery — url | upload (LINE sends media by URL) - edit_message.max_edits — edit cap per message (Feishu = 20) - delete_message.window_sec — deletion window in seconds (WeCom recall = 86400) Backfilled the platforms with known values (LINE, Discord, Feishu, WeCom); others omit them. Template + serde structs updated; 4 unit + 8 conformance tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/platform-schema/src/lib.rs | 19 +++++++++++++++++++ docs/platforms/_template.toml | 4 ++++ docs/platforms/schema/discord.toml | 2 ++ docs/platforms/schema/feishu.toml | 1 + docs/platforms/schema/line.toml | 1 + docs/platforms/schema/wecom.toml | 1 + 6 files changed, 28 insertions(+) diff --git a/crates/platform-schema/src/lib.rs b/crates/platform-schema/src/lib.rs index 2160a213b..906cbd761 100644 --- a/crates/platform-schema/src/lib.rs +++ b/crates/platform-schema/src/lib.rs @@ -193,6 +193,9 @@ pub struct EmojiReactions { #[serde(deny_unknown_fields)] pub struct EditMessage { pub supported: bool, + /// Cap on edits per message, if the platform imposes one (e.g. Feishu = 20). + #[serde(default)] + pub max_edits: Option, pub note: String, pub source: String, } @@ -211,6 +214,9 @@ pub enum DeleteScope { pub struct DeleteMessage { pub supported: bool, pub scope: DeleteScope, + /// Deletion time window in seconds, if bounded (e.g. WeCom recall = 86400). + #[serde(default)] + pub window_sec: Option, pub note: String, pub source: String, } @@ -234,6 +240,14 @@ pub enum AttachmentKind { File, } +/// How the bot delivers outbound media: by URL reference, or by uploading bytes. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutboundDelivery { + Url, + Upload, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Attachments { @@ -241,6 +255,11 @@ pub struct Attachments { pub outbound: Vec, #[serde(default)] pub max_size_mb: Option, + /// Max attachments per message, if the platform caps it (e.g. Discord = 10). + #[serde(default)] + pub max_count: Option, + #[serde(default)] + pub outbound_delivery: Option, pub note: String, pub source: String, } diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index ab660aafb..4e175aff0 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -91,6 +91,7 @@ source = "" # required [capability.edit_message] supported = false # required, bool. can a bot edit its own sent message? +max_edits = 0 # optional, int. cap on edits per message (0/omit = unspecified/unlimited) note = "" # required source = "" # required @@ -98,6 +99,7 @@ source = "" # required supported = false # required, bool # enum: none | own | others | own_and_others scope = "none" # required. what a bot may delete +window_sec = 0 # optional, int. deletion time window in seconds (0/omit = none/unlimited) note = "" # required source = "" # required @@ -112,6 +114,8 @@ source = "" # required inbound = [] # required. subset of: image audio video file (what CAN arrive) outbound = [] # required. subset of: image audio video file max_size_mb = 0 # optional, int. headline cap; 0/omit = unspecified +max_count = 0 # optional, int. max attachments per message (0/omit = unspecified) +# outbound_delivery = "upload" # optional, enum: url | upload — how the bot SENDS media # required. fetch model, provider caveats, expiry — AND per-type / per-plan size # limits when they differ from the single headline max_size_mb above. note = "" diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml index 7d6efbf36..1e634e75d 100644 --- a/docs/platforms/schema/discord.toml +++ b/docs/platforms/schema/discord.toml @@ -65,6 +65,8 @@ source = "https://docs.discord.com/developers/resources/message" inbound = ["image", "audio", "video", "file"] outbound = ["image", "audio", "video", "file"] max_size_mb = 10 +max_count = 10 +outbound_delivery = "upload" note = "Inbound & outbound arbitrary file types. Default upload cap 10 MiB per file (raised by uploader Nitro status or server Boost tier)." source = "https://docs.discord.com/developers/reference" diff --git a/docs/platforms/schema/feishu.toml b/docs/platforms/schema/feishu.toml index 3c3a32297..f84c5f967 100644 --- a/docs/platforms/schema/feishu.toml +++ b/docs/platforms/schema/feishu.toml @@ -45,6 +45,7 @@ source = "https://open.feishu.cn/document/server-docs/im-v1/message [capability.edit_message] supported = true +max_edits = 20 note = "Own message only (calling identity must equal sender, else errcode 230071). PATCH /im/v1/messages/{id} updates text / post. Officially documented hard cap of 20 edits per message (errcode 230072 when exceeded); edit-time window is set by enterprise admin (errcode 230075). Card (interactive) updates use a separate endpoint, not subject to the 20-edit cap. Global API frequency limit 1000/min / 50 QPS (errcode 230020)." source = "https://open.feishu.cn/document/server-docs/im-v1/message/update" diff --git a/docs/platforms/schema/line.toml b/docs/platforms/schema/line.toml index df7da01b8..4fc8cdcac 100644 --- a/docs/platforms/schema/line.toml +++ b/docs/platforms/schema/line.toml @@ -66,6 +66,7 @@ source = "https://developers.line.biz/en/docs/messaging-api/message-types/" [capability.attachments] inbound = ["image", "audio", "video", "file"] outbound = ["image", "audio", "video", "file"] +outbound_delivery = "url" note = "Inbound via get-content by message ID (/v2/bot/message/{id}/content on the api-data.line.me host): images, video, audio, files. contentProvider.type is \"line\" (fetchable) or \"external\" (URL only, not fetchable via get-content). User-sent content auto-expires, so fetch promptly. Outbound media is sent by URL, not upload." source = "https://developers.line.biz/en/reference/messaging-api/#get-content" diff --git a/docs/platforms/schema/wecom.toml b/docs/platforms/schema/wecom.toml index 074f8e92f..8b3301143 100644 --- a/docs/platforms/schema/wecom.toml +++ b/docs/platforms/schema/wecom.toml @@ -52,6 +52,7 @@ source = "https://developer.work.weixin.qq.com/document/path/94867" [capability.delete_message] supported = true scope = "own" +window_sec = 86400 note = "Own messages only, via recall (/cgi-bin/message/recall), within 24 hours of send; cannot delete users'/others' messages (data already delivered to the WeChat-plugin end also can't be pulled back)." source = "https://developer.work.weixin.qq.com/document/path/94867" From a8cbb409bd7a03fe37a33c66be765748c53e8c46 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Wed, 8 Jul 2026 14:47:44 +0000 Subject: [PATCH 32/37] feat(platforms): add cron_dispatch to the openab-feature closed set #1315 landed telegram cron on main; model it in the schema. Adds cron_dispatch as the 17th closed feature across all 8 platform pages (discord/slack/telegram=implemented, others=not_implemented), the template, README, and bumps schema_version to 2026-07-08. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/platform-schema/src/lib.rs | 3 ++- docs/platforms/README.md | 6 +++--- docs/platforms/_template.toml | 11 +++++++++-- docs/platforms/schema/discord.toml | 9 ++++++++- docs/platforms/schema/feishu.toml | 9 ++++++++- docs/platforms/schema/googlechat.toml | 9 ++++++++- docs/platforms/schema/line.toml | 9 ++++++++- docs/platforms/schema/slack.toml | 9 ++++++++- docs/platforms/schema/teams.toml | 9 ++++++++- docs/platforms/schema/telegram.toml | 9 ++++++++- docs/platforms/schema/wecom.toml | 9 ++++++++- 11 files changed, 78 insertions(+), 14 deletions(-) diff --git a/crates/platform-schema/src/lib.rs b/crates/platform-schema/src/lib.rs index 906cbd761..3c8ec8d05 100644 --- a/crates/platform-schema/src/lib.rs +++ b/crates/platform-schema/src/lib.rs @@ -11,7 +11,7 @@ use serde::Deserialize; /// Current schema version. Bump when the schema changes; stale files are flagged. -pub const SCHEMA_VERSION: &str = "2026-07-07"; +pub const SCHEMA_VERSION: &str = "2026-07-08"; /// The complete, closed OpenAB feature set (Schema 2). Every platform file must /// contain exactly these keys, once each. @@ -32,6 +32,7 @@ pub const EXPECTED_FEATURES: &[&str] = &[ "slash_commands", "multibot", "group_routing", + "cron_dispatch", ]; /// Every `[capability.*]` sub-section name, in template order. diff --git a/docs/platforms/README.md b/docs/platforms/README.md index f79332748..d3c4a8de3 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -9,7 +9,7 @@ This directory is **not a giant table** — it defines a **schema** that every p Each `schema/.toml` has three schema-driven parts: 1. **Platform capability** (`[capability.*]`, fixed fields) — the platform's intrinsic nature and what a bot can/can't do inside it. Same fields for every platform. Source of truth = **official docs** (each field carries a `source` URL). -2. **OpenAB feature support** (`[[openab_features]]`, the closed 16-feature set) — for each OpenAB capability, a `status` + note + code `source`. Source of truth = **our code + the PR that decided it**. +2. **OpenAB feature support** (`[[openab_features]]`, the closed 17-feature set) — for each OpenAB capability, a `status` + note + code `source`. Source of truth = **our code + the PR that decided it**. 3. **Platform quirks** (`[[quirks]]`, freeform dated log) — anything that doesn't fit a fixed field (e.g. LINE's reply/push model), plus a findings log. **Sourcing rule:** attach the source that answers *"why should I trust or keep this?"* — intrinsic `(A)` facts link the **official platform doc** (a `source` URL); OpenAB `(B)` decisions/findings point at the **code** (`file.rs#symbol`) and, where relevant, the **PR** (`pr` / `refs`). Code refs use a grep-stable `#symbol` (no line numbers), so conformance can confirm they still exist without breaking on unrelated edits above the target. @@ -18,11 +18,11 @@ Each `schema/.toml` has three schema-driven parts: `crates/platform-schema` deserializes every `schema/*.toml` into typed structs and, in CI, enforces: -- **structural validity** — required fields, closed enum sets, the exact 16-feature set, unknown-key rejection; +- **structural validity** — required fields, closed enum sets, the exact 17-feature set, unknown-key rejection; - **version currency** — every file's `schema_version` matches the current one (a stale file fails the build); - **anti-drift** — every `source` code-ref still resolves to a real file + `#symbol` in the tree. -- **Current schema version: `2026-07-07`** — the top-line `schema_version` in each file. Bump it when the schema changes; the conformance test then flags every file that hasn't been re-verified. +- **Current schema version: `2026-07-08`** — the top-line `schema_version` in each file. Bump it when the schema changes; the conformance test then flags every file that hasn't been re-verified. ## Platforms diff --git a/docs/platforms/_template.toml b/docs/platforms/_template.toml index 4e175aff0..23e6dbc78 100644 --- a/docs/platforms/_template.toml +++ b/docs/platforms/_template.toml @@ -34,7 +34,7 @@ # Version this file was written against. Bumped when the schema changes. # Conformance flags any schema/*.toml older than the current version as stale. -schema_version = "2026-07-07" # required, "YYYY-MM-DD" +schema_version = "2026-07-08" # required, "YYYY-MM-DD" [platform] name = "" # required. matches filename, snake_case @@ -169,7 +169,7 @@ source = "" # required # ═══ Schema 2 — openab-feature-support (source of truth: our code + PR) ══════ -# One [[openab_features]] block per OpenAB capability. All 16 blocks below are +# One [[openab_features]] block per OpenAB capability. All 17 blocks below are # the complete, closed feature set (conformance checks every one is present and # that no unknown feature key is used). Fill each for the platform. # @@ -292,6 +292,13 @@ note = "" source = [] pr = "" +[[openab_features]] +feature = "cron_dispatch" # scheduled cronjob.toml jobs targeting this platform +status = "" +note = "" +source = [] # e.g. ["crates/openab-core/src/cron.rs#VALID_PLATFORMS"] +pr = "" + # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ # One [[quirks]] block per gotcha / special model / structural constraint. diff --git a/docs/platforms/schema/discord.toml b/docs/platforms/schema/discord.toml index 1e634e75d..44bdc80bf 100644 --- a/docs/platforms/schema/discord.toml +++ b/docs/platforms/schema/discord.toml @@ -1,7 +1,7 @@ # Discord platform capability schema. # Generated from the adapter source + official docs — follows _template.toml. -schema_version = "2026-07-07" +schema_version = "2026-07-08" [platform] name = "discord" @@ -226,6 +226,13 @@ note = "Per-thread dispatch keyed by dispatcher.key(\"discord\", channel_id, source = ["crates/openab-core/src/discord.rs#detect_thread"] pr = "" +[[openab_features]] +feature = "cron_dispatch" +status = "implemented" +note = "`cronjob.toml` jobs with `platform = \"discord\"` fire via the shared scheduler: `discord` is in `VALID_PLATFORMS` and `main` registers the Discord adapter under `\"discord\"` in `cron_adapters`, so `fire_cronjob` dispatches through `adapter.send_message`. Cron dispatch intentionally bypasses the L2/L3 ingress trust gate — jobs are operator-authored in config, not untrusted inbound." +source = ["crates/openab-core/src/cron.rs#VALID_PLATFORMS", "crates/openab-core/src/cron.rs#fire_cronjob", "src/main.rs#cron_adapters"] +pr = "" + # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ diff --git a/docs/platforms/schema/feishu.toml b/docs/platforms/schema/feishu.toml index f84c5f967..d52396e4b 100644 --- a/docs/platforms/schema/feishu.toml +++ b/docs/platforms/schema/feishu.toml @@ -1,7 +1,7 @@ # Feishu / Lark — platform capability schema # Generated from the adapter source + official docs -schema_version = "2026-07-07" +schema_version = "2026-07-08" [platform] name = "feishu" @@ -225,6 +225,13 @@ note = "Routing keyed by chat_id; channel_type = direct (p2p) vs group; threa source = ["crates/openab-gateway/src/adapters/feishu.rs#parse_message_event"] 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 = "" + # ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ diff --git a/docs/platforms/schema/googlechat.toml b/docs/platforms/schema/googlechat.toml index 0cf79df50..4bd02224e 100644 --- a/docs/platforms/schema/googlechat.toml +++ b/docs/platforms/schema/googlechat.toml @@ -2,7 +2,7 @@ # Platform capability schema — Google Chat # ═══════════════════════════════════════════════════════════════════════════ -schema_version = "2026-07-07" +schema_version = "2026-07-08" [platform] name = "googlechat" @@ -227,6 +227,13 @@ note = "Session/route keyed on space.name + optional thread_id; ChannelInfo c source = ["crates/openab-gateway/src/adapters/googlechat.rs#send_googlechat_event"] 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 = "" + # ═══ Schema 3 — platform-quirks (freeform, dated findings log) ═══════════════ diff --git a/docs/platforms/schema/line.toml b/docs/platforms/schema/line.toml index 4fc8cdcac..d88c387af 100644 --- a/docs/platforms/schema/line.toml +++ b/docs/platforms/schema/line.toml @@ -3,7 +3,7 @@ # Converted from the adapter source + official docs. Structure follows _template.toml. # ═══════════════════════════════════════════════════════════════════════════ -schema_version = "2026-07-07" +schema_version = "2026-07-08" [platform] name = "line" @@ -228,6 +228,13 @@ note = "Channel keyed by groupId (group) / roomId (room) / userId (1:1); a gr source = ["crates/openab-gateway/src/adapters/line.rs"] pr = "#1291" +[[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 = "" + # ═══ Schema 3 — platform-quirks ══════════════════════════════════════════════ diff --git a/docs/platforms/schema/slack.toml b/docs/platforms/schema/slack.toml index 62b343e54..8e34c46eb 100644 --- a/docs/platforms/schema/slack.toml +++ b/docs/platforms/schema/slack.toml @@ -1,7 +1,7 @@ # Slack — machine-readable platform schema # Converted from the adapter source + official docs. See _template.toml for the schema. -schema_version = "2026-07-07" +schema_version = "2026-07-08" [platform] name = "slack" @@ -224,6 +224,13 @@ note = "Routed through Dispatcher; key is grouping-dependent — slack: Date: Thu, 9 Jul 2026 03:18:01 +0000 Subject: [PATCH 33/37] docs(platforms): add missing cron_dispatch to README feature table --- docs/platforms/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index d3c4a8de3..f7e1629ba 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -92,6 +92,7 @@ The closed set of OpenAB capabilities (derived from the `ChatAdapter` trait in ` | `slash_commands` | `/reset`, `/cancel` handling | | `multibot` | multiple bots in one channel | | `group_routing` | group / session routing | +| `cron_dispatch` | scheduled cron job delivery via `cronjob.toml` | ### 3 — `[[quirks]]` (platform-quirks) From b369602efd35dc45cad7d3ed3470f1512ef60f5f Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Thu, 9 Jul 2026 03:28:23 +0000 Subject: [PATCH 34/37] docs(platforms): add workflow guide for updating features, platforms, and architecture note --- docs/platforms/README.md | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index f7e1629ba..b29f48cb8 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -102,3 +102,53 @@ Freeform dated log — anything not captured by sections 1/2 (special models, go - `kind` (required): `intrinsic` (a platform fact) or `openab_decision` (a choice/finding of ours). - `source` (optional): official-doc URL, or `file.rs` / `file.rs#symbol`. - `refs` (optional): PR/ADR links, e.g. `["#1291"]`. + +--- + +## How to update + +### Adding a new feature to the closed set + +When OpenAB gains a new capability (e.g. `voice_call`), update in **one PR**: + +1. **`crates/platform-schema/src/lib.rs`** — add to `EXPECTED_FEATURES` +2. **`docs/platforms/_template.toml`** — add a `[[openab_features]]` block +3. **`docs/platforms/README.md`** — add a row to the feature table above +4. **All 8 `schema/*.toml` files** — add the feature block with appropriate status: + ```toml + [[openab_features]] + feature = "voice_call" + status = "not_implemented" # or implemented / partial / workaround / n_a + note = "..." + source = ["crates/openab-gateway/src/adapters/line.rs#handle_voice"] + pr = "#XXXX" + ``` +5. **Bump `SCHEMA_VERSION`** in `lib.rs` + update `schema_version` in all `.toml` files + +CI enforces completeness: a missing feature block in any platform file fails the build. + +### Changing a feature's status on one platform + +When an adapter adds or drops support (e.g. LINE gains streaming): + +1. Edit only that platform's `schema/.toml` +2. Update `status`, `note`, `source`, and `pr` fields +3. No other files need to change (no version bump required for status-only changes) + +### Adding a new platform + +1. Copy `_template.toml` to `schema/.toml` +2. Fill all `[capability.*]` sections (source: official platform docs) +3. Fill all 17 `[[openab_features]]` blocks (source: adapter code) +4. Add `[[quirks]]` for platform-specific behaviors +5. Add the platform to `EXPECTED_PLATFORMS` in `tests/conformance.rs` +6. Add a row to the Platforms table in this README + +### Architecture: TOML vs Markdown + +| Layer | Purpose | Audience | +|-------|---------|----------| +| `docs/platforms/schema/*.toml` | Machine-readable facts schema | CI, automation, onboarding | +| `docs/.md` | Human-readable setup/operator guide | Operators deploying OAB | + +These are **complementary, not overlapping**. TOML captures "what the platform can do + what OpenAB implements"; Markdown captures "how to configure and deploy". Update the TOML when adapter behavior changes; update the Markdown when deployment instructions change. From 0bf4d68267ef37a5d7d5b5d07e61f738b14676db Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Thu, 9 Jul 2026 03:28:58 +0000 Subject: [PATCH 35/37] docs: add platform schema section to CONTRIBUTING.md --- CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7695c737d..bd29f6024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,6 +172,18 @@ gh run view --repo openabdev/openab --json conclusion -q .conclusion - Run `cargo clippy` and address warnings - Keep PRs focused — one feature or fix per PR +## Platform Schema + +When modifying a platform adapter (`crates/openab-gateway/src/adapters/*.rs`), check whether the change affects the platform's documented capabilities or feature status. If it does, update the corresponding `docs/platforms/schema/.toml`. + +See [`docs/platforms/README.md`](docs/platforms/README.md) for: +- The three-schema structure (capability, feature-support, quirks) +- How to add a new feature to the closed set +- How to add a new platform +- Architecture: TOML (machine facts) vs `docs/.md` (human setup guide) + +CI runs conformance tests on schema changes — missing features, invalid enums, or broken code-ref `source` fields will fail the build. + ## PR Lifecycle Every PR follows a label-driven lifecycle that keeps the review loop moving. From cff9057452fec19863ee9c0b8341825c94064977 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Thu, 9 Jul 2026 03:30:29 +0000 Subject: [PATCH 36/37] docs(platforms): add architecture diagram to README --- docs/platforms/README.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/platforms/README.md b/docs/platforms/README.md index b29f48cb8..3e91e4241 100644 --- a/docs/platforms/README.md +++ b/docs/platforms/README.md @@ -105,6 +105,44 @@ Freeform dated log — anything not captured by sections 1/2 (special models, go --- +## How it all fits together + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ docs/platforms/schema/*.toml (machine-readable facts, source of truth)│ +│ │ +│ line.toml │ slack.toml │ discord.toml │ telegram.toml │ feishu.toml │…│ +└──────┬──────────────┬──────────────┬────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌──────────┐ ┌───────────────────────────────────────────┐ +│ CI │ │ Onboard │ │ Future │ +│ │ │ │ │ │ +│ conformance │ │ new │ │ • runtime capability queries │ +│ tests │ │ maintainer│ │ • auto-generated comparison tables │ +│ (Rust crate)│ │ reads │ │ • adapter scaffolding from template │ +│ │ │ schema │ │ │ +└──────┬──────┘ └──────────┘ └───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Validates: │ +│ • structural correctness (serde) │ +│ • closed 17-feature set (no gaps) │ +│ • schema version freshness │ +│ • code-ref #symbol still exists in tree│ +└─────────────────────────────────────────┘ + +Separate layer (human-facing): +┌─────────────────────────────────────────┐ +│ docs/.md │ +│ (operator setup guides — how to deploy)│ +│ NOT duplicated in TOML; lives alongside│ +└─────────────────────────────────────────┘ +``` + +--- + ## How to update ### Adding a new feature to the closed set From d8bf6b8f86f7e550dc724189878951b421d21a76 Mon Sep 17 00:00:00 2001 From: luffy-aiagent Date: Thu, 9 Jul 2026 16:23:51 +0000 Subject: [PATCH 37/37] ci(platform-schema): add --locked for reproducible conformance builds Build strictly from the committed Cargo.lock and fail if it is stale, rather than silently regenerating it. Partial fix for #1338 (--locked); Rust cache, checkout@v6, and concurrency group tracked there separately. --- .github/workflows/platform-schema-conformance.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/platform-schema-conformance.yml b/.github/workflows/platform-schema-conformance.yml index 2e78e056a..6f4a7f7d0 100644 --- a/.github/workflows/platform-schema-conformance.yml +++ b/.github/workflows/platform-schema-conformance.yml @@ -24,4 +24,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable # Standalone crate (serde + toml only); excluded from the root workspace # so it builds without the heavy adapter crates. - - run: cargo test --manifest-path crates/platform-schema/Cargo.toml + # --locked: build strictly from the committed Cargo.lock; fail if it is + # stale or missing rather than silently regenerating it (reproducible CI). + - run: cargo test --locked --manifest-path crates/platform-schema/Cargo.toml