diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f490d45c..06fef1cc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -60,7 +60,7 @@ jobs: - name: Clone upstream vercel/chat at pinned parity tag id: clone_upstream run: | - git clone --depth 1 --branch chat@4.26.0 \ + git clone --depth 1 --branch chat@4.29.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat - name: Test fidelity check (strict — zero missing in mapped core files) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796c20e9..def8d2cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,60 @@ # Changelog -## 0.4.29a3 (unreleased) +## 0.4.29 (2026-06-12) + +Synced to upstream `vercel/chat@4.29.0` (release commit `6581d31`, May 18 2026; upstream never tagged `chat@4.27.0`/`chat@4.28.0`). Headlines: **Meta Messenger adapter** (9th platform), **`chat/ai` tool factories** (`create_chat_tools`), **`callback_url` on buttons and modals**, **Transcripts API + `thread_history` rename**, **`burst` concurrency strategy**, a Slack feature wave (verifier precedence flip, external installation providers, native `markdown_text`, `web_client`), the upstream adapter-hardening security pass, and a Python floor bump to 3.12. Sets `UPSTREAM_PARITY = "4.29.0"`; CI fidelity re-pinned to `chat@4.29.0`. + +### Upstream parity ports — core (`packages/chat`) + +- **`callback_url` on buttons and modals** (vercel/chat#454). New `src/chat_sdk/callback_url.py` plus plumbing through cards, modals, chat, channel, and thread: card buttons and modals can carry a `callback_url` that the SDK POSTs to when the action/submit fires, alongside regular handlers. Serialized wire keys stay camelCase (`callbackUrl`) for cross-SDK state compatibility. +- **Transcripts API + per-thread cache rename to `thread_history`** (vercel/chat#448). `MessageHistoryCache` → `ThreadHistoryCache` (module `message_history` → `thread_history`) with back-compat shims at the old import path and config names (new name wins when both are set, matching upstream's `?? config.messageHistory` fallback). The persisted state key prefix `msg-history:` is **unchanged** (renaming would orphan existing data — upstream kept it too). New Transcripts surface for cross-platform per-user history; `ChatConfig.transcripts` requires `ChatConfig.identity` and raises on misconfiguration, matching upstream's guard. +- **`message.subject` + `fetch_subject` adapter hook** (vercel/chat#459, PR #131). `MessageSubject` dataclass, optional `BaseAdapter.fetch_subject(raw)` hook, lazily-resolved cached `Message.subject` accessor, adapter bound at the `Chat._dispatch_to_handlers` convergence point. The GitHub/Linear adapter halves are blocked on native-client exposure (see Known Non-Parity rows). +- **`burst` concurrency strategy** (vercel/chat#495, PR #114). Hybrid of `debounce` and `queue`: idle threads coalesce a burst window into one dispatch with earlier messages in `context.skipped`; busy threads drain like `queue`. Note: upstream's PR title says "queue-debounce" but the shipped strategy string is `"burst"` — the Python port matches the string (cross-SDK config parity). +- **`process_message` returns the handler task** (core slice of vercel/chat#444). Streaming callers can await full handler completion and observe handler exceptions; `wait_until` keeps swallowed-error semantics; fire-and-forget callers are unaffected. The `@chat-adapter/web` package that motivated #444 is browser-only and not ported. +- **`chat/ai` subpath** (vercel/chat#492, design #109; PRs #116 + #122). `chat_sdk.ai` is now a package: `ai/messages.py` (`to_ai_messages`, moved with deprecation shims) and `ai/tools.py` — `create_chat_tools(chat, preset=, require_approval=, overrides=)` returning the 17 upstream tool factories as `ChatTool` dataclasses (JSON-Schema `input_schema`, async `execute`, `needs_approval`), keyed by upstream's camelCase tool ids. No new runtime dependencies. + +### New adapter: Messenger (Meta) + +- **`chat_sdk.adapters.messenger`** (vercel/chat#461, design #110; PRs #118 + #124). Full Messenger Platform adapter: GET verification handshake + `X-Hub-Signature-256` HMAC verification, Graph API client with typed error mapping, text/Generic/Button-template sends with documented caps, buffered streaming (no edit API), postback/reaction/delivery/read handlers, attachment extraction with lazy `fetch_data`, local message cache backing `fetch_messages`. New extra: `chat-sdk-python[messenger]`. Capabilities matrix in the design issue; `get_user` tracked as #132. + +### Upstream parity ports — adapters + +- **Slack: `webhook_verifier` now takes precedence over `signing_secret`** (vercel/chat#468, PR #113). Reverses the 0.4.27 precedence after upstream reversed itself; see the Known Non-Parity row update. **Migration**: if you relied on `signing_secret` shadowing a configured `webhook_verifier`, remove one of the two. +- **Slack: native `markdown_text` for outgoing messages** (vercel/chat#440). Outgoing posts use Slack's native markdown rendering instead of the legacy Block Kit conversion (deferred from 0.4.27). +- **Slack: external installation providers for bot token management** (vercel/chat#467). Pluggable multi-workspace token source composing with the existing dynamic `bot_token` resolver (per-request ContextVar caching preserved). +- **Slack: `web_client` property** (vercel/chat#471/#476/#478, PR #127). Sync `slack_sdk.WebClient` bound to the request-context token; `client` retained as a one-release deprecated alias. +- **Teams: outbound file delivery via data-URI activity attachments** (PR #125, ports upstream `filesToAttachments`). Execution artifacts now reach Teams; `edit_message` delivery is a documented deliberate superset. +- **Telegram: `video_note` extraction + typed attachment uploads** (vercel/chat#457, #485; PR #119). +- **Discord: handle interactions in gateway-only mode** (vercel/chat#490). +- **Adapter hardening pass** (slices of upstream `9824d33`): Slack timing-safe socket-token comparison (PR #126), GitHub eager bot-user-ID auto-detection (PR #128), Linear OAuth tokens encrypted at rest with AES-256-GCM (PR #129), and Google Chat fail-closed webhook verification (PR #130 — **breaking** for gchat configs that previously constructed without any verification gate; set `google_chat_project_number`, `pubsub_audience`, or the explicit `disable_signature_verification=True` escape hatch). ### Documentation alignment -- **DM routing precedence docstrings** (vercel/chat#491, commit `67c1794`). The Python runtime already routed direct messages to `on_direct_message` handlers before subscribed-message, mention, and pattern handlers (`Chat._handle_incoming_message`, `chat.py:2154`), but `on_direct_message` and `Thread.subscribe` lacked docstrings clarifying that contract. Refreshed both to match upstream's #491 docstring rewrite, plus a new `tests/integration/test_dm_flow.py::TestDMRoutingDocs` group and a `test_dm_handler_runs_before_pattern_handler` regression test that pin the documented precedence (DM > pattern) the previous suite did not explicitly cover. No runtime behavior change. -- **Streaming docstring refresh** (vercel/chat#463, commit `0cc3d06`). Replaces stale "fallback mode (post + edit on adapters without native streaming)" wording on `StreamingPlanOptions.update_interval_ms` with "Used by post+edit streaming paths", matching upstream's #463 clarification (the interval is consulted regardless of whether the adapter exposes its own `stream` method — Slack/Teams may still rate-limit edits internally). Also reworks the `ThreadImpl._handle_stream` docstring + the in-function "Use native streaming if adapter supports it" comment so they no longer imply a binary native-vs-fallback split. The unrelated `step-finish` → `finish-step` upstream type fix was already correct in the Python port (`from_full_stream.py`, `thread.py`). No runtime behavior change. +- **DM routing precedence docstrings** (vercel/chat#491, PR #121). `on_direct_message` and `Thread.subscribe` docstrings now state the DM > subscribed > mention > pattern precedence the runtime already implemented; regression test pins DM > pattern. +- **Streaming docstring refresh** (vercel/chat#463, PR #123). `StreamingPlanOptions.update_interval_ms` and `ThreadImpl._handle_stream` no longer imply a binary native-vs-fallback split. + +### Python-only improvements + +- **Slack `files_upload_v2` confirmation surfaced through `post()`** (PR #117; also shipped early as 0.4.27.1). `SentMessage.raw` carries `uploaded_file_ids`; history persistence nulls `raw` to avoid storage bloat/PII. +- **Slack: DM block-action responses no longer thread** (PR #133). `_handle_block_actions` mirrors `_handle_message_event`'s DM handling so HITL button replies don't create phantom "1 reply" threads. +- **Google Chat: file delivery via multipart media upload** (PR #112). + +### Not ported / deferred (documented in docs/UPSTREAM_SYNC.md) + +- **`@chat-adapter/web`** (vercel/chat#444) — browser-only; no Python runtime. +- **`@chat-adapter/tests` kit** (vercel/chat#470) — `chat_sdk.testing` already covers the surface; row added. +- **GitHub `octokit` / Linear `linear_client` getters** (vercel/chat#459/#478 halves) — both Python adapters are hand-rolled over `aiohttp` with no SDK object to expose; rows added with the revisit conditions (`githubkit` adoption / an official Linear Python SDK). +- **Teams migration to `microsoft-teams-apps`** (issue #93) — explicitly deferred to the 0.4.30 cycle; row added. The 3.12 floor prerequisite (#111) shipped in this release. + +### Build / infra + +- **Python floor bumped 3.10 → 3.12** (PR #111). +- **Fidelity**: CI re-pinned to `chat@4.29.0`; `MAPPING` updated for the 4.29 layout (`ai.test.ts` split into `ai/messages.test.ts` + `ai/index.test.ts`) and extended to the new core test files; converter-exact test renames in `tests/test_ai_tools.py`; two `chat.test.ts` subject-rehydration ports are `skipif`-gated on `BaseAdapter.fetch_subject` and activate automatically when PR #131 lands. +- **Next wave**: upstream `chat@4.30.0` (tagged 2026-06-01) is tracked in #135. + +## 0.4.27.1 (2026-05-29) -> Version bump from `0.4.29a2` to `0.4.29a3` will be cut by a coordinating PR once the parallel 4.29 ports land; this entry intentionally leaves `pyproject.toml` unchanged. +Python-only point release on the 0.4.27 line (branched from `v0.4.27`; PR #120). Backports the Slack `files_upload_v2` confirmation fix (PR #117) so `SentMessage.raw` carries `uploaded_file_ids`, plus the history-persistence `raw` null-out. Shipped ahead of 0.4.29 to unblock chinchill-api's delivery-confirmation gating. ## 0.4.29a2 (2026-05-28) diff --git a/CLAUDE.md b/CLAUDE.md index 23269c79..dbf0e1d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Claude Code Quick Reference -- chat-sdk-python ## What is this? -Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (porting v4.29.0; last fully-synced release `0.4.27` at upstream `4.27.0`). Multi-platform async chat framework. +Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (synced to upstream v4.29.0; next wave 4.30.0 is tracked separately). Multi-platform async chat framework. ## Key Commands ```bash @@ -25,7 +25,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `0.4.26` = synced to upstream `4.26.0` - `0.4.26.3` = Python-only fixes on top of `4.26.0` - `0.4.27` = synced to upstream `4.27.0` -- `0.4.29a1` = alpha while porting upstream `4.29.0` (current branch; upstream skipped 4.28 tag) +- `0.4.27.1` = Python-only fix on top of `4.27.0` +- `0.4.29` = synced to upstream `4.29.0` (upstream never tagged 4.27/4.28 as `chat@*`) - `UPSTREAM_PARITY` constant in `__init__.py` = programmatic access ## Architecture @@ -34,7 +35,7 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `src/chat_sdk/channel.py` -- Channel (thread listing, metadata) - `src/chat_sdk/plan.py` -- Plan (PostableObject for structured task lists) - `src/chat_sdk/types.py` -- All types (Message, Author, Adapter protocol) -- `src/chat_sdk/adapters/` -- 8 platform adapters +- `src/chat_sdk/adapters/` -- 9 platform adapters - `src/chat_sdk/shared/` -- Markdown parser, format converter, streaming renderer - `src/chat_sdk/state/` -- Memory, Redis, Postgres backends - `tests/` -- 3,400+ tests @@ -108,26 +109,22 @@ will not pass CI. **Fidelity check** (`scripts/verify_test_fidelity.py`) verifies every TS `it("...")` in the mapped core files has a matching Python `def test_*()`, -pinned to `chat@4.26.0` (upstream skipped tagging `chat@4.27.0` and -`chat@4.28.0`, then resumed at `chat@4.29.0`; pin moves to `chat@4.29.0` -once the in-flight 4.29 sync lands). The `MAPPING` -dict in that script is the authoritative scope list — it currently covers 8 -of 17 `packages/chat/src/*.test.ts` files (extending it is tracked as a -follow-up). **CI runs `--strict`** (see `.github/workflows/lint.yml`): +pinned to `chat@4.29.0` (matches `UPSTREAM_PARITY`; upstream never tagged +`chat@4.27.0`/`chat@4.28.0`). The `MAPPING` dict in that script is the +authoritative scope list — extending it to the remaining unmapped +`packages/chat/src/*.test.ts` files is tracked as issue #78. +**CI runs `--strict`** (see `.github/workflows/lint.yml`): any missing translation in a mapped file fails the build, and a missing upstream checkout also fails (the script exits non-zero when any mapped TS file isn't found). Baseline mode (the default without `--strict`) is retained for local workflows where a few ports land in flight — regenerate via `--update-baseline` after documenting intentional -divergence in `docs/UPSTREAM_SYNC.md`. During this sync cycle baseline -mode reports a parity mismatch (baseline pinned at `chat@4.26.0`, -`UPSTREAM_PARITY` says `4.29.0`); that's the intended signal until the -sync lands. +divergence in `docs/UPSTREAM_SYNC.md`. Before the fidelity check can run locally, clone the pinned upstream checkout (same command CI uses in `lint.yml`): ```bash -git clone --depth 1 --branch chat@4.26.0 \ +git clone --depth 1 --branch chat@4.29.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat ``` Then `TS_ROOT=/tmp/vercel-chat uv run python scripts/verify_test_fidelity.py --strict`. diff --git a/README.md b/README.md index 9e664fdb..487ea710 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat). -> **Status: Alpha (0.4.29a1 — porting [Vercel Chat 4.29.0](https://github.com/vercel/chat))** — API may change. Last fully-synced release: `0.4.27` (parity with upstream `chat@4.27.0`). See [CHANGELOG.md](CHANGELOG.md) for the in-flight sync plan. +> **Status: 0.4.29 — synced to [Vercel Chat 4.29.0](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.29.0"`). The 4.30 sync wave is tracked in [#135](https://github.com/Chinchill-AI/chat-sdk-python/issues/135). See [CHANGELOG.md](CHANGELOG.md). ## Why chat-sdk? -- **Write once, deploy to 8 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, and Linear. +- **Write once, deploy to 9 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Messenger, Google Chat, GitHub, and Linear. - **Built-in concurrency primitives.** Deduplication, thread locking, and message queuing are handled for you. - **Cross-platform cards.** Author a `Card` once and it renders as Block Kit (Slack), Adaptive Cards (Teams), embeds (Discord), and more. - **Not a replacement for platform SDKs.** chat-sdk is built *on top of* them. You can always drop down to the native SDK when you need to. @@ -54,10 +54,29 @@ async def handle_mention(thread, message): | Teams | `chat-sdk[teams]` | Alpha | | Telegram | `chat-sdk[telegram]` | Alpha | | WhatsApp | `chat-sdk[whatsapp]` | Alpha | +| Messenger (Meta) | `chat-sdk[messenger]` | Alpha | | Google Chat | `chat-sdk[google-chat]` | Alpha | | GitHub | `chat-sdk[github]` | Alpha | | Linear | `chat-sdk[linear]` | Alpha | +## AI / LLM Integration + +Expose chat actions to an LLM agent as tools (`chat/ai` parity, vercel/chat#492): + +```python +from chat_sdk.ai import create_chat_tools, to_ai_messages + +tools = create_chat_tools(chat, preset="messenger", require_approval=True) +# {"postMessage": ChatTool(description=..., input_schema={...}, execute=..., needs_approval=True), ...} +``` + +Each `ChatTool` is SDK-agnostic: `input_schema` is a JSON-Schema dict you can +hand to any agent runtime (Anthropic tool use, OpenAI tools, pydantic-ai, ...), +`execute` is the async implementation, and `needs_approval` flags write tools +for human-in-the-loop gating. Presets: `reader`, `messenger`, `moderator`. +`to_ai_messages(thread)` converts thread history into model-ready messages. +Runnable demo: [`examples/ai_tools_example.py`](examples/ai_tools_example.py). + ## State Backends | Backend | Install Extra | @@ -70,7 +89,7 @@ async def handle_mention(thread, message): | Feature | chat-sdk | Raw platform SDKs | BotFramework SDK | |---------|----------|--------------------|------------------| -| Multi-platform from one codebase | 8 platforms | 1 per SDK | Teams + limited | +| Multi-platform from one codebase | 9 platforms | 1 per SDK | Teams + limited | | Async-native (Python 3.12+) | Yes | Varies | No | | Cross-platform cards | Card model | Platform-specific | Adaptive Cards only | | Thread locking / dedup | Built-in | DIY | DIY | diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 4269a08f..f40f7efe 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -14,6 +14,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre | `0.4.26` | `4.26.0` | Synced to upstream 4.26.0 | | `0.4.26.3` | `4.26.0` | Python-only fixes on top of 4.26.0 | | `0.4.27` | `4.27.0` | Synced to upstream 4.27.0 | +| `0.4.27.1` | `4.27.0` | Python-only fix on top of 4.27.0 (Slack upload confirmation backport) | +| `0.4.29` | `4.29.0` | Synced to upstream 4.29.0 (upstream never tagged `chat@4.27.0`/`chat@4.28.0`) | The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access to the upstream version this release is synced to. @@ -77,12 +79,11 @@ tests. If upstream tests lock in inconsistent behavior, choose one of: ### Test fidelity (strict mode) `scripts/verify_test_fidelity.py` runs in CI (`.github/workflows/lint.yml`) pinned -to `vercel/chat@4.26.0` (matches the `UPSTREAM_PARITY` constant in +to `vercel/chat@4.29.0` (matches the `UPSTREAM_PARITY` constant in `src/chat_sdk/__init__.py`). **CI runs `--strict`** — the repo ships at 0 -missing *for mapped core files* as of `0.4.26.2` and the baseline -(`scripts/fidelity_baseline.json`) is empty. Scope is defined by the -`MAPPING` dict in the script: 8 of 17 `packages/chat/src/*.test.ts` files -today (extending to the remaining 9 is tracked as a follow-up). Unmapped +missing *for mapped core files* as of `0.4.29`. Scope is defined by the +`MAPPING` dict in the script (extending to the remaining unmapped +`packages/chat/src/*.test.ts` files is tracked as issue #78). Unmapped files are not checked — tightening scope requires editing `MAPPING` and re-running `--strict`. @@ -635,6 +636,13 @@ stay explicit instead of being rediscovered in code review. | `_rehydrate_message` with `Message` input | Falls through to the `rehydrate_attachment` pass even when the dequeued entry is already a `Message` instance | Early-returns on `raw instanceof Message` before rehydration | The Python port's Redis + Postgres `dequeue()` upgrade raw JSON to `Message.from_json(...)` before returning (upstream's dequeue returns the raw JSON.parse'd dict). Upstream's `instanceof Message` shortcut therefore only fires for in-memory state, but ours would fire for persistent backends too, leaving `fetch_data` stripped forever. The rehydrate pass still skips any attachment that already has `fetch_data`, so in-memory callers pay no cost. | | Slack Socket Mode reconnect loop | Outer reconnect loop on top of `slack_sdk.socket_mode.aiohttp.SocketModeClient` (which itself has `auto_reconnect_enabled=True`). Exponential backoff (1s → 30s) with explicit shutdown signaling and a tracked `asyncio.Task` so `disconnect()` can cancel cleanly | Single `SocketModeClient` instance from `@slack/socket-mode`; relies entirely on the package's internal reconnect | Hazard #5 (async task lifecycle): a long-lived WebSocket needs an explicit shutdown path so `disconnect()` doesn't leak the loop, and a guarded outer reconnect path so the adapter survives `connect()` itself raising (which the inner client doesn't retry). Inner auto-reconnect still runs; the outer loop is belt-and-suspenders, not a divergence in observable behavior. | | Slack Socket Mode listener serverless variant | Not ported | `startSocketModeListener()` / `runSocketModeListener()` open a transient socket for `durationMs` and forward events via HTTP POST | Vercel-specific pattern (cron-triggered ephemeral listener with `waitUntil`). The forwarded-event receiver (`x-slack-socket-token` handling in `handle_webhook`) is ported so a separate Python process can run the long-lived listener; the deployment glue itself isn't part of the SDK. | +| `GitHubAdapter.octokit` native client getter (vercel/chat#459, #478) | Not exposed | `get octokit(): Octokit` (plus deprecated `client` alias) returns the underlying Octokit — fixed instance in PAT/single-tenant App mode, per-installation client resolved from `AsyncLocalStorage` inside a webhook handler in multi-tenant mode | The Python adapter is hand-rolled over raw `aiohttp` (`_github_api_request`) with PyJWT for App JWTs and an installation-token cache; the `github` extra is `pyjwt[crypto]` only — there is no Octokit-equivalent object to return, and exposing the raw session or an invented facade under the name `octokit` would misrepresent the surface. Revisit if the adapter adopts an octokit-style SDK (e.g. `githubkit`) as an optional dependency per hazard #10's "prefer official SDKs" sub-rule; the getter (and the GitHub `fetch_subject` half of #459) ports cleanly then. | +| `LinearAdapter.linear_client` native client getter (vercel/chat#459, #478) | Not exposed | `get linearClient(): LinearClient` (plus deprecated `client` alias) returns the `@linear/sdk` `LinearClient`, per-org from `AsyncLocalStorage` in multi-tenant OAuth mode | `@linear/sdk` is TypeScript-only and no official Linear Python SDK exists; the adapter issues GraphQL directly over `aiohttp` (`_graphql_query`) and already documents that stance. Nothing honest to put behind the name. Revisit only if Linear ships an official Python SDK (the Linear `fetch_subject` half of #459 is blocked on the same). | +| `@chat-adapter/tests` adapter test kit (vercel/chat#470) | Not ported | New TS package with test utilities for adapter authors | Python already ships `chat_sdk.testing` (`MockAdapter`, `MockStateAdapter`, `create_test_message()`) covering the same surface for this repo's adapter tests; mirroring the TS kit verbatim would duplicate it. Revisit if upstream's kit grows capabilities ours lacks (e.g. recorded replay fixtures for third-party adapter authors). | +| Teams modal-submit webhook options (vercel/chat#454 adapter-teams slice) | Not ported — the Python Teams adapter has no task-module/modal-submit flow (`handleTaskSubmit`/`processModalSubmit` are absent), so upstream's change passing `bridgeAdapter.getWebhookOptions(activity.id)` into `processModalSubmit` has no landing site | `TeamsAdapter.handleTaskSubmit` forwards webhook options so modal callbackUrl POSTs are registered with `waitUntil` | Pre-existing gap: Teams modals are unported. The Slack adapter already forwards options to `process_modal_submit`, so the new waitUntil plumbing is exercised there. Add the Teams call when Teams modal support lands. | +| jsx-runtime `callbackUrl` props (vercel/chat#454 slice) | Not ported | `ButtonProps`/`ModalProps` gain `callbackUrl`; `resolveJSXElement` forwards it | Covered by the existing "JSX Card/Modal elements" row — Python has no JSX runtime; `Button()`/`Modal()` builders accept `callback_url` directly. | +| Transcripts API Python adaptations (vercel/chat#448) | `transcripts.delete()` returns a `DeleteResult` dataclass; misconfiguration raises `ValueError` (constructor/`AppendInput` guards, invalid duration) or `ChatError` (`chat.transcripts` accessor); guard messages name the Python kwarg (`options.user_key`); `DurationString` is a `str` alias validated at runtime by `_parse_duration` | Inline `{ deleted: number }`; generic `Error` for all of the above; template-literal `` `${number}${"s"\|"m"\|"h"\|"d"}` `` type | Port rules: typed dataclasses over raw dicts; repo error-type conventions (constructor misconfig → `ValueError`, runtime API misuse → `ChatError`) with upstream-matching message wording; Python has no template-literal types. Same shapes and values throughout. | +| Slack legacy mrkdwn renderer (response_url surface only, post-#440) | `_node_to_mrkdwn` renders headings as `*bold*` and images as `{alt} ({url})` / bare URL | TS `nodeToMrkdwn` has no heading/image branches — both fall through to `defaultNodeToText`, dropping heading emphasis and image URLs | Pre-existing Python improvement; after vercel/chat#440 it affects only `to_response_url_text` (ephemeral edits via response_url). Preserves visual hierarchy and image URLs Slack would otherwise lose. | ### Platform-specific gaps @@ -644,7 +652,9 @@ stay explicit instead of being rediscovered in code review. | Teams `dialog_open_timeout_ms` config | Not implemented | Configurable | Low demand | | Google Chat file uploads | Ignored in message parse | Supported | API complexity; can add later | | Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection | +| Discord gateway-only interactions (vercel/chat#490) | Handled on the forwarded-event surface: a `GATEWAY_INTERACTION_CREATE` envelope (raw INTERACTION_CREATE dispatch payload in `data`) is deferred via `POST /interactions/{id}/{token}/callback` (type 5 slash / type 6 component) and routed through the existing HTTP interaction handlers; a malformed forward missing `id`/`token` is logged and skipped | Upstream handles `Events.InteractionCreate` directly on the resident discord.js client via `deferReply()`/`deferUpdate()`; the upstream forwarder never forwards interactions | Python has no resident Gateway client (row above), so gateway-only deployments run an external listener shim that forwards raw dispatch payloads (`x-discord-gateway-token`). Observable wire behavior is identical — same callback REST calls, same handler routing, same `@original` deferred-response resolution. | | Teams `User-Agent: Vercel.ChatSDK` outbound header | Not set on `aiohttp` calls | Propagated by `botbuilder` 2.0.8 | Python Teams adapter doesn't use `botbuilder` (raw `aiohttp`). Upstream's vercel/chat#415 was a JS-only `botbuilder` SDK bump that flipped `X-User-Agent` → `User-Agent`. No equivalent dependency to bump on the Python side. Setting a `User-Agent` on the ~9 outbound `aiohttp` call sites would be a defense-in-depth nice-to-have; deferred to a follow-up. | +| Teams adapter on `microsoft-teams-apps` (official MS Python SDK) | Hand-rolled Bot Framework REST + JWT verification (see the transitional native-streaming rows above) | `@microsoft/teams.apps` owns the wire format, throttling, and activity routing | **Explicitly deferred to the 0.4.30 cycle** (issue #93). The Python SDK only went GA 2026-05-01; the migration is a 4–6 day restructuring of `adapter.py` (~2,300 LOC) + 6 test files and was too large to land inside the 4.29 wave's tail. The 3.12 floor bump (#111) — the migration's prerequisite — already landed in 0.4.29. | | Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | | WhatsApp `get_user` | Raises `ChatNotImplementedError` (`Chat.get_user` translates to "does not support get_user") | Not implemented upstream either (no `getUser` on the WhatsApp adapter) | WhatsApp Cloud API has no user lookup endpoint — phone numbers are the only stable identifier and there's no equivalent of `users.info` exposed to business apps. Documented explicitly so callers don't expect parity with Slack/Teams/Discord. | diff --git a/examples/ai_tools_example.py b/examples/ai_tools_example.py new file mode 100644 index 00000000..6dfeb17a --- /dev/null +++ b/examples/ai_tools_example.py @@ -0,0 +1,98 @@ +"""Example: expose chat-sdk actions to an LLM agent via ``create_chat_tools``. + +Runs entirely against the in-memory ``MockAdapter`` — no platform +credentials required: + + uv run python examples/ai_tools_example.py + +``create_chat_tools(chat, ...)`` returns the 17 upstream tool factories +(vercel/chat#492) as ``ChatTool`` dataclasses keyed by upstream's camelCase +tool ids, each carrying: + +- ``description`` — model-facing description +- ``input_schema`` — JSON-Schema dict for the tool arguments +- ``execute`` — async callable taking the validated argument dict +- ``needs_approval`` — human-in-the-loop flag (write tools default True) + +The dataclasses are SDK-agnostic on purpose: bind them into whatever agent +runtime you use by translating ``input_schema`` into that runtime's schema +layer and calling ``execute`` from its tool dispatcher. The sketch at the +bottom shows the Anthropic tool-use shape; chinchill-api does the same via +its own runner. +""" + +from __future__ import annotations + +import asyncio + +from chat_sdk import Chat, ChatConfig +from chat_sdk.ai import create_chat_tools +from chat_sdk.testing import MockAdapter, MockStateAdapter, create_test_message + + +async def main() -> None: + adapter = MockAdapter(name="slack") + chat = Chat( + ChatConfig( + user_name="examplebot", + adapters={"slack": adapter}, + state=MockStateAdapter(), + ) + ) + await chat.webhooks["slack"]("request") # triggers adapter/state init + + # ------------------------------------------------------------------ + # 1. Build the toolset. Presets scope what the model may do: + # "reader" (fetch/list only), "messenger" (reader + posting), + # "moderator" (everything, including delete/reactions). + # ------------------------------------------------------------------ + tools = create_chat_tools( + chat, + preset="messenger", + require_approval={"postMessage": False}, # auto-approve plain posts + overrides={"postMessage": {"description": "Post a markdown reply into the current thread."}}, + ) + + print(f"toolset ({len(tools)} tools):") + for name, tool in sorted(tools.items()): + gate = "needs approval" if tool.needs_approval else "auto" + print(f" {name:24s} [{gate}] {tool.description[:60]}") + + # ------------------------------------------------------------------ + # 2. Execute a tool the way an agent runtime would: validated args in, + # JSON-safe result out. + # ------------------------------------------------------------------ + thread_id = "slack:C123:1234.5678" + await chat.handle_incoming_message(adapter, thread_id, create_test_message("msg-1", "Hey @examplebot hello")) + + result = await tools["postMessage"].execute( + { + "threadId": thread_id, + "message": {"markdown": "Hello from the example agent!"}, + } + ) + print(f"\npostMessage -> {result}") + + fetched = await tools["fetchMessages"].execute({"threadId": thread_id}) + print(f"fetchMessages -> {len(fetched['messages'])} message(s)") + + # ------------------------------------------------------------------ + # 3. Binding sketch (Anthropic tool-use shape; any runtime works): + # + # anthropic_tools = [ + # { + # "name": name, + # "description": tool.description, + # "input_schema": tool.input_schema, + # } + # for name, tool in tools.items() + # ] + # ... when the model emits a tool_use block: + # if tools[block.name].needs_approval: + # ...ask a human first... + # result = await tools[block.name].execute(block.input) + # ------------------------------------------------------------------ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 74bd2683..2fe0761f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.29a2" +version = "0.4.29" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", @@ -55,6 +55,7 @@ discord = ["pynacl>=1.5", "aiohttp>=3.9"] teams = ["aiohttp>=3.9"] telegram = ["aiohttp>=3.9"] whatsapp = ["aiohttp>=3.9"] +messenger = ["aiohttp>=3.9"] google-chat = ["aiohttp>=3.9", "pyjwt[crypto]>=2.8", "google-auth>=2.0"] linear = ["aiohttp>=3.9"] all = [ diff --git a/scripts/fidelity_baseline.json b/scripts/fidelity_baseline.json index d9cfc428..a4e46fea 100644 --- a/scripts/fidelity_baseline.json +++ b/scripts/fidelity_baseline.json @@ -1,7 +1,7 @@ { "_comment": "Ratchet-down baseline for scripts/verify_test_fidelity.py. This repo ships at strict fidelity for mapped core files (0 missing) against chat@4.26.0, so the baseline is empty. Scope: the MAPPING dict in scripts/verify_test_fidelity.py is the authoritative list of TS files checked; it currently covers 8 of the 17 packages/chat/src/*.test.ts files. Default CI mode runs --strict via .github/workflows/lint.yml; this file is retained for local workflows that want to opt back into baseline mode (e.g. during an upstream sync where several ports land in flight). To baseline genuinely-divergent tests, run scripts/verify_test_fidelity.py --update-baseline after documenting the divergence in docs/UPSTREAM_SYNC.md.", - "ts_parity": "chat@4.26.0", - "total_ts_tests": 588, + "ts_parity": "chat@4.29.0", + "total_ts_tests": 731, "total_missing": 0, "missing": {} } diff --git a/scripts/verify_test_fidelity.py b/scripts/verify_test_fidelity.py index e559019f..e36a1334 100644 --- a/scripts/verify_test_fidelity.py +++ b/scripts/verify_test_fidelity.py @@ -14,10 +14,9 @@ ``--strict`` is the current CI contract (see ``.github/workflows/lint.yml``): the baseline is ignored and any missing translation — or a missing upstream checkout — fails the build. This repo ships at strict fidelity for mapped -core files (0 missing) against ``chat@4.26.0``. The ``MAPPING`` dict below -is the authoritative scope list; it currently covers 8 of the 17 -``packages/chat/src/*.test.ts`` files (extending it is tracked as a -follow-up). +core files (0 missing) against ``chat@4.29.0``. The ``MAPPING`` dict below +is the authoritative scope list (extending it to the remaining unmapped +``packages/chat/src/*.test.ts`` files is tracked as issue #78). Baseline mode (the default without ``--strict``) is retained for local workflows where a few ports land in flight: it succeeds iff the set of @@ -45,7 +44,14 @@ "packages/chat/src/markdown.test.ts": "tests/test_markdown_faithful.py", "packages/chat/src/streaming-markdown.test.ts": "tests/test_streaming_markdown.py", "packages/chat/src/serialization.test.ts": "tests/test_serialization.py", - "packages/chat/src/ai.test.ts": "tests/test_ai_messages.py", + # chat@4.29.0 moved ai.test.ts into ai/ and split it (vercel/chat#492) + "packages/chat/src/ai/messages.test.ts": "tests/test_ai_messages.py", + "packages/chat/src/ai/index.test.ts": "tests/test_ai_tools.py", + # New core test files in chat@4.29.0 + "packages/chat/src/callback-url.test.ts": "tests/test_callback_url.py", + "packages/chat/src/thread-history.test.ts": "tests/test_thread_history.py", + "packages/chat/src/transcripts.test.ts": "tests/test_transcripts.py", + "packages/chat/src/transcripts-wiring.test.ts": "tests/test_transcripts_wiring.py", "packages/chat/src/from-full-stream.test.ts": "tests/test_from_full_stream.py", } @@ -275,7 +281,7 @@ def load_baseline(path: Path) -> dict[str, set[tuple[str, str]]]: "against the current UPSTREAM_PARITY tag, so the baseline is " "normally empty. Scope: the MAPPING dict in " "scripts/verify_test_fidelity.py is the authoritative list of TS " - "files checked; it currently covers 8 of the 17 " + "files checked (extending to the remaining unmapped files is issue #78) " "packages/chat/src/*.test.ts files. Default CI mode runs --strict " "via .github/workflows/lint.yml; this file is retained for local " "workflows that want to opt back into baseline mode (e.g. during " @@ -311,7 +317,7 @@ def write_baseline(path: Path, all_missing: dict[str, list], total_ts: int) -> N current_parity = _current_parity_tag() payload = { "_comment": existing_comment if existing_comment is not None else _DEFAULT_BASELINE_COMMENT, - "ts_parity": current_parity if current_parity is not None else "chat@4.26.0", + "ts_parity": current_parity if current_parity is not None else "chat@4.29.0", "total_ts_tests": total_ts, "total_missing": sum(len(v) for v in all_missing.values()), "missing": { @@ -440,7 +446,7 @@ def main() -> int: print(f" - {path}") print( "\nClone the upstream repo at the pinned parity tag, e.g.:\n" - " git clone --depth 1 --branch chat@4.26.0 " + " git clone --depth 1 --branch chat@4.29.0 " "https://github.com/vercel/chat.git /tmp/vercel-chat\n" "then re-run with TS_ROOT=/tmp/vercel-chat." ) diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 7b121a84..c7a13a1c 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -65,6 +65,8 @@ from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError, RateLimitError, StateNotConnectedError from chat_sdk.from_full_stream import from_full_stream from chat_sdk.logger import ConsoleLogger, Logger, LogLevel + +# Deprecated aliases — renamed to ThreadHistoryCache / ThreadHistoryConfig. from chat_sdk.message_history import MessageHistoryCache, MessageHistoryConfig from chat_sdk.modals import ( ExternalSelect, @@ -121,10 +123,13 @@ from chat_sdk.shared.streaming_markdown import StreamingMarkdownRenderer from chat_sdk.state.memory import MemoryStateAdapter from chat_sdk.thread import ThreadImpl +from chat_sdk.thread_history import ThreadHistoryCache, ThreadHistoryConfig from chat_sdk.types import ( ActionEvent, Adapter, AdapterPostableMessage, + AppendInput, + AppendOptions, AppHomeOpenedEvent, AssistantContextChangedEvent, AssistantThreadStartedEvent, @@ -138,6 +143,10 @@ ChatInstance, ConcurrencyConfig, ConcurrencyStrategy, + CountQuery, + DeleteResult, + DeleteTarget, + DurationString, EmojiFormats, EmojiValue, EphemeralMessage, @@ -146,7 +155,10 @@ FetchResult, FileUpload, FormattedContent, + IdentityContext, + IdentityResolver, LinkPreview, + ListQuery, ListThreadsOptions, ListThreadsResult, Lock, @@ -185,6 +197,10 @@ Thread, ThreadInfo, ThreadSummary, + TranscriptEntry, + TranscriptRole, + TranscriptsApi, + TranscriptsConfig, UserInfo, WebhookOptions, WellKnownEmoji, @@ -298,7 +314,10 @@ "ConsoleLogger", "Logger", "LogLevel", - # Message history + # Thread history (per-thread cache) + "ThreadHistoryCache", + "ThreadHistoryConfig", + # Thread history — deprecated aliases (renamed upstream) "MessageHistoryCache", "MessageHistoryConfig", # Modal builders (PascalCase primary — matches source TS SDK) @@ -335,6 +354,8 @@ "ActionEvent", "Adapter", "AdapterPostableMessage", + "AppendInput", + "AppendOptions", "AppHomeOpenedEvent", "AssistantContextChangedEvent", "AssistantThreadStartedEvent", @@ -348,6 +369,10 @@ "ChatInstance", "ConcurrencyConfig", "ConcurrencyStrategy", + "CountQuery", + "DeleteResult", + "DeleteTarget", + "DurationString", "EmojiFormats", "EmojiValue", "EphemeralMessage", @@ -356,7 +381,10 @@ "FetchResult", "FileUpload", "FormattedContent", + "IdentityContext", + "IdentityResolver", "LinkPreview", + "ListQuery", "ListThreadsOptions", "ListThreadsResult", "Lock", @@ -396,6 +424,10 @@ "Thread", "ThreadInfo", "ThreadSummary", + "TranscriptEntry", + "TranscriptRole", + "TranscriptsApi", + "TranscriptsConfig", "UserInfo", "WebhookOptions", "WellKnownEmoji", diff --git a/src/chat_sdk/adapters/discord/__init__.py b/src/chat_sdk/adapters/discord/__init__.py index e689df0d..fdf4307a 100644 --- a/src/chat_sdk/adapters/discord/__init__.py +++ b/src/chat_sdk/adapters/discord/__init__.py @@ -1,5 +1,14 @@ """Discord adapter for chat-sdk.""" from chat_sdk.adapters.discord.adapter import DiscordAdapter, create_discord_adapter +from chat_sdk.adapters.discord.cards import ( + decode_discord_custom_id, + encode_discord_custom_id, +) -__all__ = ["DiscordAdapter", "create_discord_adapter"] +__all__ = [ + "DiscordAdapter", + "create_discord_adapter", + "decode_discord_custom_id", + "encode_discord_custom_id", +] diff --git a/src/chat_sdk/adapters/discord/adapter.py b/src/chat_sdk/adapters/discord/adapter.py index dbcfd9f6..f01195f7 100644 --- a/src/chat_sdk/adapters/discord/adapter.py +++ b/src/chat_sdk/adapters/discord/adapter.py @@ -20,6 +20,7 @@ from chat_sdk.adapters.discord.cards import ( card_to_discord_payload, + decode_discord_custom_id, ) from chat_sdk.adapters.discord.format_converter import DiscordFormatConverter from chat_sdk.adapters.discord.types import ( @@ -385,10 +386,11 @@ def _handle_component_interaction( }, ) + decoded = decode_discord_custom_id(custom_id) self._chat.process_action( ActionEvent( - action_id=custom_id, - value=custom_id, + action_id=decoded.action_id, + value=decoded.value if decoded.value is not None else decoded.action_id, user=Author( user_id=user.get("id", ""), user_name=user.get("username", ""), @@ -505,10 +507,20 @@ def _parse_slash_command( command_parts: list[str] = [name if name.startswith("/") else f"/{name}"] value_parts: list[str] = [] + def stringify(value: Any) -> str: + # Match TS `String(value)` for boolean options: JSON booleans + # arrive as Python bools, and `str(True)` would emit "True" + # where upstream emits "true" (e.g. a `verbose: true` option). + if value is True: + return "true" + if value is False: + return "false" + return str(value) + def collect(items: list[DiscordCommandOption]) -> None: for option in items: if option.get("value") is not None: - value_parts.append(str(option["value"])) + value_parts.append(stringify(option["value"])) continue sub_options = option.get("options", []) if sub_options: @@ -541,11 +553,110 @@ async def _handle_forwarded_gateway_event( await self._handle_forwarded_reaction(event.get("data", {}), True, options) elif event_type == "GATEWAY_MESSAGE_REACTION_REMOVE": await self._handle_forwarded_reaction(event.get("data", {}), False, options) + elif event_type == "GATEWAY_INTERACTION_CREATE": + await self._handle_forwarded_interaction(event.get("data", {}), options) else: self._logger.debug("Forwarded Gateway event (no handler)", {"type": event_type}) return self._make_json_response(json.dumps({"ok": True}), 200) + async def _handle_forwarded_interaction( + self, + interaction: DiscordInteraction, + options: WebhookOptions | None = None, + ) -> None: + """Handle a forwarded INTERACTION_CREATE event (gateway-only mode). + + Discord sends interactions through either the Gateway or an + Interactions Endpoint URL, not both (vercel/chat#490). Deployments + that leave the endpoint URL unset receive interactions over the + Gateway; the forwarder relays the raw INTERACTION_CREATE dispatch + payload here, which is already in wire format, so the existing HTTP + interaction handlers consume it unchanged. + + Unlike HTTP interactions -- where the deferral rides the HTTP + response body -- a gateway interaction is acknowledged with an + explicit REST call to the interaction callback endpoint. That is + the same wire call upstream's gateway-only handler makes via + discord.js ``deferReply()`` (slash commands) and ``deferUpdate()`` + (components). Slash commands then route through the existing slash + command handler path (the deferred response is later resolved by + ``post_message`` PATCHing the ``@original`` interaction webhook + message), and component interactions route through the existing + action handler path. + """ + interaction_id = interaction.get("id") + interaction_token = interaction.get("token") + interaction_type = interaction.get("type", 0) + + self._logger.info( + "Discord Gateway interaction received", + {"id": interaction_id, "type": interaction_type}, + ) + + if interaction_type not in ( + INTERACTION_TYPE_APPLICATION_COMMAND, + INTERACTION_TYPE_MESSAGE_COMPONENT, + ): + self._logger.debug( + "Forwarded Gateway interaction (no handler)", + {"type": interaction_type}, + ) + return + + if not (interaction_id and interaction_token): + # A gateway INTERACTION_CREATE always carries id + token; a + # malformed forward must not produce a garbage callback URL. + self._logger.warn( + "Forwarded Gateway interaction missing id or token", + {"id": interaction_id, "type": interaction_type}, + ) + return + + try: + if interaction_type == INTERACTION_TYPE_APPLICATION_COMMAND: + # deferReply: ACK now, respond via the interaction webhook later. + await self._defer_gateway_interaction( + interaction_id, + interaction_token, + InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + ) + self._handle_application_command_interaction(interaction, options) + return + + # deferUpdate: ACK the component, update the message later. + await self._defer_gateway_interaction( + interaction_id, + interaction_token, + InteractionResponseType.DEFERRED_UPDATE_MESSAGE, + ) + self._handle_component_interaction(interaction, options) + except Exception as error: + self._logger.error( + "Error handling Gateway interaction", + {"error": str(error), "interactionId": interaction_id}, + ) + + async def _defer_gateway_interaction( + self, + interaction_id: str, + interaction_token: str, + response_type: int, + ) -> None: + """ACK a gateway-received interaction via the callback endpoint. + + ``POST /interactions/{id}/{token}/callback`` is the REST equivalent + of returning the deferral as the HTTP response body on the + Interactions Endpoint path. Path segments are URL-quoted so a + crafted id/token in a forwarded payload cannot pivot the request + (hazard #12, same guard as :meth:`get_user`). + """ + await self._discord_fetch( + f"/interactions/{quote(interaction_id, safe='')}/{quote(interaction_token, safe='')}/callback", + "POST", + {"type": response_type}, + ) + async def _handle_forwarded_message( self, data: DiscordGatewayMessageData, diff --git a/src/chat_sdk/adapters/discord/cards.py b/src/chat_sdk/adapters/discord/cards.py index c5114b37..7744e407 100644 --- a/src/chat_sdk/adapters/discord/cards.py +++ b/src/chat_sdk/adapters/discord/cards.py @@ -7,6 +7,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from chat_sdk.adapters.discord.types import DiscordActionRow, DiscordButton @@ -24,6 +25,7 @@ ) from chat_sdk.emoji import convert_emoji_placeholders from chat_sdk.shared.card_utils import render_gfm_table +from chat_sdk.shared.errors import ValidationError # Discord button styles (discord-api-types/v10 ButtonStyle) BUTTON_STYLE_PRIMARY = 1 @@ -34,6 +36,46 @@ # Discord blurple color DISCORD_BLURPLE = 0x5865F2 +# Discord packs the button value into custom_id (max 100 chars). The +# delimiter is a newline -- it can't appear in a Button id and survives +# Discord's round-trip intact. +DISCORD_CUSTOM_ID_DELIMITER = "\n" +DISCORD_CUSTOM_ID_MAX_LENGTH = 100 + + +@dataclass(frozen=True) +class DecodedDiscordCustomId: + """Result of :func:`decode_discord_custom_id`.""" + + action_id: str + value: str | None + + +def _validate_discord_custom_id(custom_id: str) -> None: + if len(custom_id) == 0 or len(custom_id) > DISCORD_CUSTOM_ID_MAX_LENGTH: + raise ValidationError( + "discord", + f"Discord custom_id must be 1-{DISCORD_CUSTOM_ID_MAX_LENGTH} characters. Shorten the button id or value.", + ) + + +def encode_discord_custom_id(action_id: str, value: str | None = None) -> str: + """Encode a button's action ID and optional value into a custom_id.""" + if value is None or value == "": + _validate_discord_custom_id(action_id) + return action_id + encoded = f"{action_id}{DISCORD_CUSTOM_ID_DELIMITER}{value}" + _validate_discord_custom_id(encoded) + return encoded + + +def decode_discord_custom_id(custom_id: str) -> DecodedDiscordCustomId: + """Split a custom_id back into (action_id, value). Splits on the first delimiter only.""" + idx = custom_id.find(DISCORD_CUSTOM_ID_DELIMITER) + if idx == -1: + return DecodedDiscordCustomId(action_id=custom_id, value=None) + return DecodedDiscordCustomId(action_id=custom_id[:idx], value=custom_id[idx + 1 :]) + def _convert_emoji(text: str) -> str: """Convert emoji placeholders to Discord format.""" @@ -172,7 +214,7 @@ def _convert_button_element(button: ButtonElement) -> DiscordButton: "type": 2, # Button "style": _get_button_style(button.get("style")), "label": button.get("label", ""), - "custom_id": button.get("id", ""), + "custom_id": encode_discord_custom_id(button.get("id", ""), button.get("value")), } if button.get("disabled"): diff --git a/src/chat_sdk/adapters/discord/types.py b/src/chat_sdk/adapters/discord/types.py index c803c789..e6e1a720 100644 --- a/src/chat_sdk/adapters/discord/types.py +++ b/src/chat_sdk/adapters/discord/types.py @@ -306,7 +306,13 @@ class DiscordGatewayReactionData(TypedDict, total=False): class DiscordForwardedEvent(TypedDict): - """A Gateway event forwarded to the webhook endpoint.""" + """A Gateway event forwarded to the webhook endpoint. + + Known types: ``GATEWAY_MESSAGE_CREATE``, ``GATEWAY_MESSAGE_REACTION_ADD``, + ``GATEWAY_MESSAGE_REACTION_REMOVE``, ``GATEWAY_INTERACTION_CREATE``. + For ``GATEWAY_INTERACTION_CREATE``, ``data`` is the raw wire-format + INTERACTION_CREATE dispatch payload (:class:`DiscordInteraction`). + """ data: Any timestamp: int diff --git a/src/chat_sdk/adapters/messenger/__init__.py b/src/chat_sdk/adapters/messenger/__init__.py index 0e86386b..11d9d6b8 100644 --- a/src/chat_sdk/adapters/messenger/__init__.py +++ b/src/chat_sdk/adapters/messenger/__init__.py @@ -1,10 +1,17 @@ """Messenger (Meta) adapter for chat-sdk. -PR 1 of 2 (scaffolding): types, format converter, and card conversion. -The adapter itself (webhook routing, Graph API, signature verification, -send/stream) is added in PR 2. +Python port of upstream ``packages/adapter-messenger``. Supports webhook +routing (with HMAC-SHA256 signature verification), Send API integration via +the Meta Graph API, and message/card/streaming primitives. """ +from chat_sdk.adapters.messenger.adapter import ( + DEFAULT_API_VERSION, + GRAPH_API_BASE, + MESSENGER_MESSAGE_LIMIT, + MessengerAdapter, + create_messenger_adapter, +) from chat_sdk.adapters.messenger.cards import ( MessengerCardResult, card_to_messenger, @@ -30,6 +37,10 @@ ) __all__ = [ + "DEFAULT_API_VERSION", + "GRAPH_API_BASE", + "MESSENGER_MESSAGE_LIMIT", + "MessengerAdapter", "MessengerAdapterConfig", "MessengerButton", "MessengerButtonTemplatePayload", @@ -47,6 +58,7 @@ "MessengerWebhookPayload", "card_to_messenger", "card_to_messenger_text", + "create_messenger_adapter", "decode_messenger_callback_data", "encode_messenger_callback_data", ] diff --git a/src/chat_sdk/adapters/messenger/adapter.py b/src/chat_sdk/adapters/messenger/adapter.py new file mode 100644 index 00000000..2d795a60 --- /dev/null +++ b/src/chat_sdk/adapters/messenger/adapter.py @@ -0,0 +1,1211 @@ +"""Messenger (Meta) adapter for chat SDK. + +Supports messaging via the Meta Messenger Platform (Graph API). +All conversations are 1:1 DMs between the Page and a user (PSID). + +Python port of ``packages/adapter-messenger/src/index.ts`` (PR 2 of 2 of +the Messenger port; PR 1 added types, format converter, and cards). + +See: https://developers.facebook.com/docs/messenger-platform +""" + +from __future__ import annotations + +import hashlib +import hmac +import inspect +import json +import os +import time +from collections.abc import AsyncIterable +from datetime import datetime, timezone +from typing import Any, Literal, cast +from urllib.parse import parse_qs, urlparse + +from chat_sdk.adapters.messenger.cards import ( + MessengerCardResultTemplate, + MessengerCardResultText, + card_to_messenger, + decode_messenger_callback_data, +) +from chat_sdk.adapters.messenger.format_converter import MessengerFormatConverter +from chat_sdk.adapters.messenger.types import ( + ENV_APP_SECRET, + ENV_PAGE_ACCESS_TOKEN, + ENV_VERIFY_TOKEN, + MessengerAdapterConfig, + MessengerMessagingEvent, + MessengerRawMessage, + MessengerSendApiResponse, + MessengerTemplatePayload, + MessengerThreadId, + MessengerUserProfile, + MessengerWebhookPayload, +) +from chat_sdk.emoji import convert_emoji_placeholders, default_emoji_resolver +from chat_sdk.errors import ChatNotImplementedError +from chat_sdk.logger import ConsoleLogger, Logger +from chat_sdk.shared.adapter_utils import extract_card +from chat_sdk.shared.errors import ( + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError, +) +from chat_sdk.types import ( + ActionEvent, + AdapterPostableMessage, + Attachment, + Author, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + LockScope, + Message, + MessageMetadata, + PostableMarkdown, + RawMessage, + ReactionEvent, + StreamChunk, + StreamOptions, + ThreadInfo, + UserInfo, + WebhookOptions, +) + +# Meta Graph API base URL +GRAPH_API_BASE = "https://graph.facebook.com" +# Default Graph API version (matches upstream) +DEFAULT_API_VERSION = "v21.0" +# Maximum text length the Send API will accept in a single message +MESSENGER_MESSAGE_LIMIT = 2000 +# Suffix that encodes a sequence number on Send-API mids (``mid.abc:2`` etc.) +# Used to disambiguate identical-timestamp messages in the local cache. +_MESSAGE_SEQUENCE_SUFFIX = ":" + + +class MessengerAdapter: + """Messenger (Meta) adapter for chat SDK. + + Implements the chat-sdk ``Adapter`` interface for the Messenger Platform. + """ + + def __init__(self, config: MessengerAdapterConfig) -> None: + # Upstream's constructor takes the resolved credentials directly. Our + # ``MessengerAdapterConfig`` exposes them via ``resolved_*`` helpers so + # the constructor and the factory both go through the same fallback + # chain. Constructing the adapter without resolved credentials is a + # programmer error — the factory enforces presence, and direct callers + # opt in to env-var fallbacks by leaving fields as ``None``. + app_secret = config.resolved_app_secret() + page_access_token = config.resolved_page_access_token() + verify_token = config.resolved_verify_token() + if not app_secret: + raise ValidationError( + "messenger", + f"appSecret is required. Set {ENV_APP_SECRET} or provide it in config.", + ) + if not page_access_token: + raise ValidationError( + "messenger", + f"pageAccessToken is required. Set {ENV_PAGE_ACCESS_TOKEN} or provide it in config.", + ) + if not verify_token: + raise ValidationError( + "messenger", + f"verifyToken is required. Set {ENV_VERIFY_TOKEN} or provide it in config.", + ) + + self._name = "messenger" + self._lock_scope: LockScope = "channel" + self._persist_message_history = True + + self._app_secret = app_secret + self._page_access_token = page_access_token + self._verify_token = verify_token + self._api_version = config.api_version if config.api_version is not None else DEFAULT_API_VERSION + self._graph_api_url = f"{GRAPH_API_BASE}/{self._api_version}" + self._logger: Logger = config.logger + self._format_converter = MessengerFormatConverter() + + # If a user_name is provided we treat it as explicit and never + # overwrite it from ``/me`` / ``chat.get_user_name()`` — matches the + # upstream ``hasExplicitUserName`` gate. ``is not None`` (not truthy) + # so an explicit ``user_name=""`` is respected rather than silently + # replaced by the ``"bot"`` fallback. + self._has_explicit_user_name = config.user_name is not None + self._user_name = config.user_name if config.user_name is not None else "bot" + + self._chat: ChatInstance | None = None + self._bot_user_id: str | None = None + + # Local caches. Messenger has no message-history API, so the adapter + # holds every parsed message in memory to back ``fetch_messages``. + self._message_cache: dict[str, list[Message]] = {} + self._user_profile_cache: dict[str, MessengerUserProfile] = {} + + # Shared aiohttp session for connection pooling; created lazily. + self._http_session: Any | None = None + + # ========================================================================= + # Adapter interface properties + # ========================================================================= + + @property + def name(self) -> str: + return self._name + + @property + def lock_scope(self) -> LockScope: + return self._lock_scope + + @property + def persist_message_history(self) -> bool: + return self._persist_message_history + + @property + def user_name(self) -> str: + return self._user_name + + @property + def bot_user_id(self) -> str | None: + return self._bot_user_id + + # ========================================================================= + # Lifecycle + # ========================================================================= + + async def initialize(self, chat: ChatInstance) -> None: + """Initialize the adapter and fetch Page identity (best-effort).""" + self._chat = chat + + if not self._has_explicit_user_name: + self._user_name = chat.get_user_name() + + try: + me = await self._graph_api_fetch("me", method="GET") + self._bot_user_id = cast(str, me.get("id")) + name = me.get("name") + if not self._has_explicit_user_name and name: + self._user_name = name + self._logger.info( + "Messenger adapter initialized", + {"botUserId": self._bot_user_id, "userName": self._user_name}, + ) + except Exception as error: + # Match upstream: identity fetch failure is non-fatal; the + # adapter still functions for incoming webhook events. + self._logger.warn( + "Failed to fetch Messenger page identity", + {"error": str(error)}, + ) + + async def disconnect(self) -> None: + """Cleanup hook. Close the shared HTTP session if it was created.""" + if self._http_session is not None and not getattr(self._http_session, "closed", True): + await self._http_session.close() + self._http_session = None + + async def _get_http_session(self) -> Any: + """Return the shared aiohttp session, creating it lazily on first use.""" + import aiohttp + + if self._http_session is None or getattr(self._http_session, "closed", True): + self._http_session = aiohttp.ClientSession() + return self._http_session + + # ========================================================================= + # Webhook + # ========================================================================= + + async def handle_webhook( + self, + request: Any, + options: WebhookOptions | None = None, + ) -> Any: + """Handle an incoming webhook request. + + - GET: webhook subscription verification challenge. + - POST: event notifications (messages, postbacks, reactions, ...). + """ + method = getattr(request, "method", "POST") + if method == "GET": + return self._handle_verification(request) + + body = await self._get_request_body(request) + + signature = self._get_header(request, "x-hub-signature-256") + if not self._verify_signature(body, signature): + self._logger.warn("Messenger webhook rejected due to invalid signature") + return self._make_response("Invalid signature", 403) + + try: + payload: MessengerWebhookPayload = json.loads(body) + except (json.JSONDecodeError, ValueError): + return self._make_response("Invalid JSON", 400) + + if payload.get("object") != "page": + return self._make_response("Not a page subscription", 404) + + if not self._chat: + self._logger.warn("Chat instance not initialized, ignoring Messenger webhook") + return self._make_response("EVENT_RECEIVED", 200) + + for entry in payload.get("entry", []): + for event in entry.get("messaging", []): + message = event.get("message") + if message and not message.get("is_echo"): + self._handle_incoming_message(event, options) + elif message and message.get("is_echo"): + self._handle_echo(event) + + if event.get("postback"): + self._handle_postback(event, options) + + if event.get("reaction"): + self._handle_reaction(event, options) + + delivery = event.get("delivery") + if delivery: + self._logger.debug( + "Message delivery confirmation", + { + "watermark": delivery.get("watermark"), + "mids": delivery.get("mids"), + }, + ) + + read = event.get("read") + if read: + self._logger.debug( + "Message read confirmation", + {"watermark": read.get("watermark")}, + ) + + return self._make_response("EVENT_RECEIVED", 200) + + def _handle_verification(self, request: Any) -> Any: + """Handle the GET subscription challenge.""" + url = getattr(request, "url", "") + parsed = urlparse(str(url)) + params = parse_qs(parsed.query) + + mode = (params.get("hub.mode") or [None])[0] + token = (params.get("hub.verify_token") or [None])[0] + challenge = (params.get("hub.challenge") or [""])[0] + + if mode == "subscribe" and token == self._verify_token: + self._logger.info("Messenger webhook verified") + return self._make_response(challenge, 200) + + self._logger.warn("Messenger webhook verification failed") + return self._make_response("Forbidden", 403) + + def _verify_signature(self, body: bytes, signature: str | None) -> bool: + """Verify the ``X-Hub-Signature-256`` header (HMAC-SHA256, App Secret). + + Format: ``sha256=``. Returns ``False`` on any malformed input. + Q3 (see #110): we keep upstream's hard-wired ``app_secret``-based + HMAC scheme. A swappable verifier (the pattern used by the Slack + adapter) would diverge from upstream's contract and isn't justified + for a single-secret Meta integration; flagged as a possible future + divergence but not introduced here. + """ + if not signature: + return False + + # Header is ``algo=hash``. Reject anything that isn't sha256 hex. + parts = signature.split("=", 1) + if len(parts) != 2: + return False + algo, hash_hex = parts + if algo != "sha256" or not hash_hex: + return False + + try: + computed_hex = hmac.new( + self._app_secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + # Compare hex strings. ``hexdigest()`` is lowercase; Node's + # ``Buffer.from(hex)`` is case-insensitive, so upstream accepts an + # uppercase-hex signature. Normalize the header hash to lowercase + # before the constant-time compare to match that behavior. + return hmac.compare_digest(hash_hex.lower(), computed_hex) + except Exception: + self._logger.warn("Failed to verify Messenger webhook signature") + return False + + # ========================================================================= + # Event handlers + # ========================================================================= + + def _handle_incoming_message( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + if not self._chat: + return + + sender = event.get("sender") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender.get("id", ""))) + + parsed = self._parse_messenger_message(event, thread_id) + self._cache_message(parsed) + self._chat.process_message(self, thread_id, parsed, options) + + def _handle_echo(self, event: MessengerMessagingEvent) -> None: + """Cache echoed (bot-sent) messages but don't dispatch them.""" + if not event.get("message"): + return + + recipient = event.get("recipient") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=recipient.get("id", ""))) + + parsed = self._parse_messenger_message(event, thread_id) + self._cache_message(parsed) + + def _handle_postback( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + postback = event.get("postback") + if not (self._chat and postback): + return + + sender = event.get("sender") or {} + sender_id = sender.get("id", "") + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender_id)) + + decoded = decode_messenger_callback_data(postback.get("payload")) + action_id = decoded.get("action_id") or "" + value = decoded.get("value") + + message_id = postback.get("mid") or f"postback:{event.get('timestamp', 0)}" + + self._chat.process_action( + ActionEvent( + adapter=self, + thread=None, # filled in by Chat + thread_id=thread_id, + message_id=message_id, + user=Author( + user_id=sender_id, + user_name=sender_id, + full_name=sender_id, + is_bot=False, + is_me=False, + ), + action_id=action_id, + value=value, + raw=event, + ), + options, + ) + + def _handle_reaction( + self, + event: MessengerMessagingEvent, + options: WebhookOptions | None = None, + ) -> None: + reaction = event.get("reaction") + if not (self._chat and reaction): + return + + sender = event.get("sender") or {} + sender_id = sender.get("id", "") + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender_id)) + + added = reaction.get("action") == "react" + raw_emoji = reaction.get("emoji", "") + # Resolve to an EmojiValue using the gchat-style raw unicode resolver, + # mirroring upstream's ``defaultEmojiResolver.fromGChat(...)`` call. + emoji_value = default_emoji_resolver.from_gchat(raw_emoji) + + self._chat.process_reaction( + ReactionEvent( + adapter=self, + thread=None, # pyrefly: ignore[bad-argument-type] # filled in by Chat + thread_id=thread_id, + message_id=reaction.get("mid", ""), + user=Author( + user_id=sender_id, + user_name=sender_id, + full_name=sender_id, + is_bot=False, + is_me=False, + ), + emoji=emoji_value, + raw_emoji=raw_emoji, + added=added, + raw=event, + ), + options, + ) + + # ========================================================================= + # Sending messages + # ========================================================================= + + async def post_message( + self, + thread_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Send a message to a Messenger user.""" + card = extract_card(message) + if card: + result = card_to_messenger(card) + if result.get("type") == "template": + template_payload = cast(MessengerCardResultTemplate, result)["payload"] + # Convert emoji placeholders inside the JSON-encoded payload + # to mirror upstream's ``convertEmojiPlaceholders`` pass. + converted = cast( + MessengerTemplatePayload, + json.loads( + convert_emoji_placeholders( + json.dumps(template_payload), + "messenger", + ) + ), + ) + return await self._send_template_message(thread_id, converted) + # Text fallback + return await self._send_text_message( + thread_id, + convert_emoji_placeholders( + cast(MessengerCardResultText, result)["text"], + "messenger", + ), + ) + + # Regular text/markdown/AST message + text = convert_emoji_placeholders( + self._format_converter.render_postable(message), + "messenger", + ) + return await self._send_text_message(thread_id, text) + + async def _send_text_message(self, thread_id: str, text: str) -> RawMessage: + """Send a plain text message via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + truncated = self._truncate_message(text) + + if not truncated.strip(): + raise ValidationError("messenger", "Message text cannot be empty") + + result: MessengerSendApiResponse = await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "message": {"text": truncated}, + "messaging_type": "RESPONSE", + }, + ) + + raw_event: MessengerMessagingEvent = { + "sender": {"id": self._bot_user_id or ""}, + "recipient": {"id": recipient_id}, + "timestamp": int(time.time() * 1000), + "message": { + "mid": result["message_id"], + "text": truncated, + "is_echo": True, + }, + } + + parsed = self._parse_messenger_message(raw_event, thread_id) + self._cache_message(parsed) + + return RawMessage( + id=result["message_id"], + thread_id=thread_id, + raw=raw_event, + ) + + async def _send_template_message( + self, + thread_id: str, + payload: MessengerTemplatePayload, + ) -> RawMessage: + """Send a Generic / Button template message via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + + result: MessengerSendApiResponse = await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "message": { + "attachment": { + "type": "template", + "payload": payload, + }, + }, + "messaging_type": "RESPONSE", + }, + ) + + raw_event: MessengerMessagingEvent = { + "sender": {"id": self._bot_user_id or ""}, + "recipient": {"id": recipient_id}, + "timestamp": int(time.time() * 1000), + "message": { + "mid": result["message_id"], + "is_echo": True, + }, + } + + parsed = self._parse_messenger_message(raw_event, thread_id) + self._cache_message(parsed) + + return RawMessage( + id=result["message_id"], + thread_id=thread_id, + raw=raw_event, + ) + + async def edit_message( + self, + thread_id: str, + message_id: str, + message: AdapterPostableMessage, + ) -> RawMessage: + """Messenger Send API does not support editing — raises.""" + raise ValidationError("messenger", "Messenger does not support editing messages") + + async def stream( + self, + thread_id: str, + text_stream: AsyncIterable[str | StreamChunk], + options: StreamOptions | None = None, + ) -> RawMessage: + """Buffer all stream chunks and send as a single message. + + Messenger doesn't support message edits, so we can't incrementally + update the same message. Mirrors upstream's behavior exactly. + """ + accumulated = "" + async for chunk in text_stream: + if isinstance(chunk, str): + accumulated += chunk + elif getattr(chunk, "type", None) == "markdown_text": + accumulated += getattr(chunk, "text", "") + return await self.post_message(thread_id, PostableMarkdown(markdown=accumulated)) + + async def delete_message(self, thread_id: str, message_id: str) -> None: + """Messenger Send API does not support deleting — raises.""" + raise ValidationError("messenger", "Messenger does not support deleting messages") + + async def add_reaction( + self, + thread_id: str, + message_id: str, + emoji: EmojiValue | str, + ) -> None: + """Messenger Send API does not expose reaction send — raises.""" + raise ValidationError("messenger", "Messenger does not support reactions via API") + + async def remove_reaction( + self, + thread_id: str, + message_id: str, + emoji: EmojiValue | str, + ) -> None: + """Messenger Send API does not expose reaction send — raises.""" + raise ValidationError("messenger", "Messenger does not support reactions via API") + + async def start_typing(self, thread_id: str, status: str | None = None) -> None: + """Send a ``typing_on`` sender_action via the Send API.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + await self._graph_api_fetch( + "me/messages", + method="POST", + body={ + "recipient": {"id": recipient_id}, + "sender_action": "typing_on", + }, + ) + + # ========================================================================= + # Fetching + # ========================================================================= + + async def fetch_messages( + self, + thread_id: str, + options: FetchOptions | None = None, + ) -> FetchResult: + """Fetch messages from the local cache (Messenger has no history API).""" + opts = options or FetchOptions() + messages = list(self._message_cache.get(thread_id, [])) + messages.sort(key=self._sort_key) + return self._paginate_messages(messages, opts) + + async def fetch_message( + self, + thread_id: str, + message_id: str, + ) -> Message | None: + """Fetch a single cached message by ID across all thread caches.""" + for messages in self._message_cache.values(): + for msg in messages: + if msg.id == message_id: + return msg + return None + + async def fetch_thread(self, thread_id: str) -> ThreadInfo: + """Fetch thread info, hydrated with the user profile when available.""" + recipient_id = self._resolve_thread_id(thread_id).recipient_id + profile = await self._fetch_user_profile(recipient_id) + display_name = self._profile_display_name(profile) + + # On Messenger every conversation is a 1:1 DM, so channel == thread. + return ThreadInfo( + id=thread_id, + channel_id=thread_id, + channel_name=display_name, + is_dm=True, + metadata={"profile": dict(profile)}, + ) + + async def fetch_channel_info(self, channel_id: str) -> ChannelInfo: + """Fetch channel info (channel == thread on Messenger).""" + recipient_id = self._resolve_thread_id(channel_id).recipient_id + profile = await self._fetch_user_profile(recipient_id) + display_name = self._profile_display_name(profile) + + return ChannelInfo( + id=channel_id, + name=display_name, + is_dm=True, + metadata={"profile": dict(profile)}, + ) + + # ========================================================================= + # Thread ID encoding + # ========================================================================= + + def channel_id_from_thread_id(self, thread_id: str) -> str: + """On Messenger every conversation is a 1:1 DM, channel == thread.""" + return thread_id + + def is_dm(self, thread_id: str) -> bool: + """All Messenger conversations are DMs.""" + return True + + async def open_dm(self, user_id: str) -> str: + """Open a DM with a user. Returns the encoded thread ID.""" + return self.encode_thread_id(MessengerThreadId(recipient_id=user_id)) + + def encode_thread_id(self, platform_data: MessengerThreadId) -> str: + """Encode a Messenger thread ID. Format: ``messenger:{recipientId}``.""" + return f"messenger:{platform_data.recipient_id}" + + def decode_thread_id(self, thread_id: str) -> MessengerThreadId: + """Decode a Messenger thread ID. Format: ``messenger:{recipientId}``.""" + parts = thread_id.split(":") + if parts[0] != "messenger" or len(parts) != 2: + raise ValidationError("messenger", f"Invalid Messenger thread ID: {thread_id}") + recipient_id = parts[1] + if not recipient_id: + raise ValidationError("messenger", f"Invalid Messenger thread ID: {thread_id}") + return MessengerThreadId(recipient_id=recipient_id) + + def _resolve_thread_id(self, value: str) -> MessengerThreadId: + """Resolve a value to a ``MessengerThreadId``. + + Accepts both encoded thread IDs (``messenger:PSID``) and bare PSIDs, + mirroring upstream's ``resolveThreadId`` helper. + """ + if value.startswith("messenger:"): + return self.decode_thread_id(value) + return MessengerThreadId(recipient_id=value) + + # ========================================================================= + # Parsing + # ========================================================================= + + def parse_message(self, raw: MessengerRawMessage) -> Message: + """Parse a raw messaging event into a normalized ``Message``.""" + sender = raw.get("sender") or {} + message_data = raw.get("message") or {} + # For echoes (bot-sent messages echoed back) ``sender.id`` is the Page + # ID and ``recipient.id`` is the user's PSID — the reverse of a normal + # inbound event. Thread off the user PSID so the message threads under + # the same conversation ``_handle_echo`` uses. + if message_data.get("is_echo"): + recipient = raw.get("recipient") or {} + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=recipient.get("id", ""))) + else: + thread_id = self.encode_thread_id(MessengerThreadId(recipient_id=sender.get("id", ""))) + message = self._parse_messenger_message(raw, thread_id) + self._cache_message(message) + return message + + def render_formatted(self, content: FormattedContent) -> str: + """Render formatted AST content back to Messenger text.""" + return self._format_converter.from_ast(content) + + def _parse_messenger_message( + self, + event: MessengerMessagingEvent, + thread_id: str, + ) -> Message: + message = event.get("message") or {} + postback = event.get("postback") or {} + sender = event.get("sender") or {} + text = message.get("text") or postback.get("title") or "" + is_echo = bool(message.get("is_echo")) + is_me = is_echo or (sender.get("id") == self._bot_user_id and self._bot_user_id is not None) + + mid = message.get("mid") + timestamp = event.get("timestamp", 0) + msg_id = mid if mid else f"event:{timestamp}" + + return Message( + id=msg_id, + thread_id=thread_id, + text=text, + formatted=self._format_converter.to_ast(text), + raw=event, + author=Author( + user_id=sender.get("id", ""), + user_name=sender.get("id", ""), + full_name=sender.get("id", ""), + is_bot=is_me, + is_me=is_me, + ), + metadata=MessageMetadata( + date_sent=datetime.fromtimestamp(timestamp / 1000.0, tz=timezone.utc), + edited=False, + ), + attachments=self._extract_attachments(event), + is_mention=True, + ) + + def _extract_attachments(self, event: MessengerMessagingEvent) -> list[Attachment]: + message = event.get("message") or {} + attachments = message.get("attachments") or [] + result: list[Attachment] = [] + for attachment in attachments: + payload = attachment.get("payload") or {} + url = payload.get("url") + if not url: + continue + result.append( + Attachment( + type=self._map_attachment_type(attachment.get("type", "")), + url=url, + fetch_data=self._make_attachment_downloader(url), + # Persist the download URL so the closure can be rebuilt + # by ``rehydrate_attachment`` after the message survives + # a queue/debounce/burst JSON roundtrip (which drops the + # ``fetch_data`` closure). Messenger payload URLs are + # signature-gated by Meta and require no auth header, so + # the URL alone is sufficient to rebuild the closure. + fetch_metadata={"url": url}, + ) + ) + return result + + def _make_attachment_downloader(self, url: str) -> Any: + """Build a closure that downloads ``url`` lazily. + + Kept as a named helper so the ``url`` capture isn't subject to + late-binding when multiple attachments are extracted from a single + event. + """ + + async def _download() -> bytes: + return await self._download_attachment(url) + + return _download + + def rehydrate_attachment(self, attachment: Attachment) -> Attachment: + """Reconstruct ``fetch_data`` on a deserialized Messenger attachment. + + Called by :class:`~chat_sdk.chat.Chat` during message rehydration in + the queue/debounce/burst concurrency paths, where the original + ``fetch_data`` closure was dropped during JSON serialization. Reads + the download URL from ``attachment.fetch_metadata`` (populated by + :meth:`_extract_attachments`) and rebuilds the lazy downloader that + reuses the shared aiohttp session. Returns the attachment unchanged + when no URL is present — matching the upstream documented "leave + unchanged when no hook" degraded-mode behavior. + + Mirrors :meth:`WhatsAppAdapter.rehydrate_attachment` exactly (both + platforms are Meta-family and share the same queue-mode failure + shape). Like the original ``_download_attachment``, the rebuilt + closure attaches no auth headers — Messenger payload URLs are + signature-gated by Meta and do not require Bearer tokens. + """ + meta = attachment.fetch_metadata if attachment.fetch_metadata is not None else {} + url = meta.get("url") + if not url: + return attachment + return Attachment( + type=attachment.type, + url=attachment.url, + name=attachment.name, + mime_type=attachment.mime_type, + size=attachment.size, + width=attachment.width, + height=attachment.height, + data=attachment.data, + fetch_data=self._make_attachment_downloader(url), + fetch_metadata=attachment.fetch_metadata, + ) + + @staticmethod + def _map_attachment_type(fb_type: str) -> Literal["audio", "file", "image", "video"]: + """Map a Messenger attachment type to the SDK ``Attachment.type`` enum.""" + if fb_type == "image": + return "image" + if fb_type == "video": + return "video" + if fb_type == "audio": + return "audio" + return "file" + + async def get_user(self, user_id: str) -> UserInfo | None: + """Messenger has no user-lookup parity with the other adapters yet. + + Mirrors :meth:`BaseAdapter.get_user`: raises + :class:`~chat_sdk.errors.ChatNotImplementedError`, which + ``Chat.get_user`` translates into a "does not support get_user" + ``ChatError``. A real Graph-API-backed implementation is tracked as + issue #132. + """ + raise ChatNotImplementedError(self.name, "getUser") + + async def _download_attachment(self, url: str) -> bytes: + """Download an attachment payload URL. Wraps errors in ``NetworkError``.""" + try: + session = await self._get_http_session() + async with session.get(url) as response: + if response.status != 200: + raise NetworkError( + "messenger", + f"Failed to download Messenger attachment: {response.status}", + ) + return await response.read() + except NetworkError: + raise + except Exception as error: + raise NetworkError( + "messenger", + "Failed to download Messenger attachment", + original_error=error if isinstance(error, Exception) else None, + ) from error + + # ========================================================================= + # User profile (with cache) + # ========================================================================= + + async def _fetch_user_profile(self, user_id: str) -> MessengerUserProfile: + cached = self._user_profile_cache.get(user_id) + # ``is not None`` (not truthy): an empty-dict ``{}`` cache entry is + # falsy and would otherwise trigger a re-fetch on every call. + if cached is not None: + return cached + + try: + profile = await self._graph_api_fetch( + user_id, + method="GET", + query_params={"fields": "first_name,last_name,profile_pic"}, + ) + if not isinstance(profile, dict): + return {"id": user_id} + typed_profile = cast(MessengerUserProfile, profile) + self._user_profile_cache[user_id] = typed_profile + return typed_profile + except Exception: + # On any error, fall back to a minimal profile carrying just the + # user ID. Matches upstream's silent fallback. + return {"id": user_id} + + @staticmethod + def _profile_display_name(profile: MessengerUserProfile) -> str: + parts = [p for p in (profile.get("first_name"), profile.get("last_name")) if p] + if parts: + return " ".join(parts) + return profile.get("id", "") + + # ========================================================================= + # Cache / pagination + # ========================================================================= + + def _cache_message(self, message: Message) -> None: + existing = self._message_cache.get(message.thread_id, []) + for i, item in enumerate(existing): + if item.id == message.id: + existing[i] = message + break + else: + existing.append(message) + existing.sort(key=self._sort_key) + self._message_cache[message.thread_id] = existing + + @staticmethod + def _sort_key(message: Message) -> tuple[float, int]: + """Sort by ``(date_sent, sequence_suffix)``. + + Messages with the same timestamp but a ``:N`` sequence suffix on the + ID are ordered by N. Mirrors upstream's ``compareMessages``. + """ + date_value = message.metadata.date_sent.timestamp() if message.metadata else 0.0 + seq = 0 + if _MESSAGE_SEQUENCE_SUFFIX in message.id: + tail = message.id.rsplit(_MESSAGE_SEQUENCE_SUFFIX, 1)[1] + if tail.isdigit(): + seq = int(tail) + return (date_value, seq) + + @staticmethod + def _paginate_messages(messages: list[Message], options: FetchOptions) -> FetchResult: + limit = max(1, min(options.limit if options.limit is not None else 50, 100)) + direction = options.direction or "backward" + + if not messages: + return FetchResult(messages=[]) + + index_by_id = {m.id: i for i, m in enumerate(messages)} + + if direction == "backward": + end = index_by_id[options.cursor] if options.cursor and options.cursor in index_by_id else len(messages) + start = max(0, end - limit) + page = messages[start:end] + return FetchResult( + messages=page, + next_cursor=(page[0].id if start > 0 and page else None), + ) + + # forward + start = index_by_id[options.cursor] + 1 if options.cursor and options.cursor in index_by_id else 0 + end = min(len(messages), start + limit) + page = messages[start:end] + return FetchResult( + messages=page, + next_cursor=(page[-1].id if end < len(messages) and page else None), + ) + + # ========================================================================= + # Send-API helpers + # ========================================================================= + + @staticmethod + def _truncate_message(text: str) -> str: + if len(text) <= MESSENGER_MESSAGE_LIMIT: + return text + return f"{text[: MESSENGER_MESSAGE_LIMIT - 3]}..." + + async def _graph_api_fetch( + self, + endpoint: str, + *, + method: str, + body: dict[str, Any] | None = None, + query_params: dict[str, str] | None = None, + ) -> Any: + """Call the Meta Graph API with the page access token. + + Wraps the call in standardized adapter errors. Mirrors the upstream + ``graphApiFetch`` helper's surface (URL building, query params, JSON + parse-then-status check, error-code mapping). + """ + session = await self._get_http_session() + url = f"{self._graph_api_url}/{endpoint}" + params: dict[str, str] = {"access_token": self._page_access_token} + if query_params: + params.update(query_params) + + try: + if method == "GET": + response_ctx = session.get(url, params=params) + else: + response_ctx = session.post( + url, + params=params, + headers={"Content-Type": "application/json"}, + json=body, + ) + + async with response_ctx as response: + status = response.status + try: + data = await response.json(content_type=None) + except Exception as error: + raise NetworkError( + "messenger", + f"Failed to parse Messenger API response for {endpoint}", + ) from error + + if status < 200 or status >= 300: + self._throw_graph_api_error(endpoint, status, data or {}) + + return data + except NetworkError: + raise + except AdapterRateLimitError: + raise + except AuthenticationError: + raise + except ResourceNotFoundError: + raise + except ValidationError: + raise + except Exception as error: + raise NetworkError( + "messenger", + f"Network error calling Messenger Graph API {endpoint}", + original_error=error if isinstance(error, Exception) else None, + ) from error + + @staticmethod + def _throw_graph_api_error( + endpoint: str, + status: int, + data: dict[str, Any], + ) -> None: + """Translate a non-2xx Graph API response to a typed adapter error.""" + error = data.get("error") if isinstance(data, dict) else None + if not isinstance(error, dict): + error = {} + message = error.get("message") or f"Messenger API {endpoint} failed" + code = error.get("code") if error.get("code") is not None else status + + # Rate limiting: HTTP 429 or known Meta rate-limit codes. + if status == 429 or code in (4, 32, 613): + raise AdapterRateLimitError("messenger") + # Auth: HTTP 401 or Meta auth code 190. + if status == 401 or code == 190: + raise AuthenticationError("messenger", message) + # Permission: HTTP 403 or Meta permission codes 10 / 200. + if status == 403 or code in (10, 200): + raise ValidationError("messenger", message) + # Not found: HTTP 404. + if status == 404: + raise ResourceNotFoundError("messenger", endpoint) + raise NetworkError( + "messenger", + f"{message} (status {status}, code {code})", + ) + + # ========================================================================= + # Request helpers (framework-agnostic) + # ========================================================================= + + @staticmethod + async def _get_request_body(request: Any) -> bytes: + """Extract the raw request body as bytes (sync or async). + + Returned as raw bytes so signature verification operates on the exact + wire bytes Meta signed. Any encode/decode round-trip risks replacement + characters or altered code points and breaks HMAC parity. + """ + body = getattr(request, "body", None) + if body is not None: + if callable(body): + result = body() + body = await result if inspect.isawaitable(result) else result + if isinstance(body, (bytes, bytearray)): + return bytes(body) + if isinstance(body, str): + return body.encode("utf-8") + text_attr = getattr(request, "text", None) + if text_attr is not None: + if callable(text_attr): + result = text_attr() + text_attr = await result if inspect.isawaitable(result) else result + if isinstance(text_attr, (bytes, bytearray)): + return bytes(text_attr) + if isinstance(text_attr, str): + return text_attr.encode("utf-8") + return b"" + + @staticmethod + def _get_header(request: Any, name: str) -> str | None: + """Get a header value from a request object (case-insensitive).""" + if hasattr(request, "headers"): + headers = request.headers + if isinstance(headers, dict): + for k, v in headers.items(): + if k.lower() == name.lower(): + return v + return None + return headers.get(name) + return None + + @staticmethod + def _make_response(body: str, status: int) -> dict[str, Any]: + """Create a framework-agnostic response dict.""" + return {"body": body, "status": status} + + +# ============================================================================= +# Factory +# ============================================================================= + + +def create_messenger_adapter( + *, + app_secret: str | None = None, + page_access_token: str | None = None, + verify_token: str | None = None, + api_version: str | None = None, + logger: Logger | None = None, + user_name: str | None = None, +) -> MessengerAdapter: + """Factory for ``MessengerAdapter`` with env-var fallbacks. + + Q1 (see #110): init-failure behavior. We match the upstream contract by + raising ``ValidationError`` at construction time for each missing required + credential. This improves on the sibling ``WhatsAppAdapter``, whose + constructor does no validation (direct misconstruction raises ``TypeError``, + not ``ValidationError``) -- Messenger raises a descriptive ``ValidationError`` + from both the factory and the constructor. It surfaces config errors loudly + during startup rather than at first webhook call. + """ + _logger = logger or ConsoleLogger("info").child("messenger") + + _app_secret = app_secret or os.environ.get(ENV_APP_SECRET) + if not _app_secret: + raise ValidationError( + "messenger", + f"appSecret is required. Set {ENV_APP_SECRET} or provide it in config.", + ) + + _page_access_token = page_access_token or os.environ.get(ENV_PAGE_ACCESS_TOKEN) + if not _page_access_token: + raise ValidationError( + "messenger", + f"pageAccessToken is required. Set {ENV_PAGE_ACCESS_TOKEN} or provide it in config.", + ) + + _verify_token = verify_token or os.environ.get(ENV_VERIFY_TOKEN) + if not _verify_token: + raise ValidationError( + "messenger", + f"verifyToken is required. Set {ENV_VERIFY_TOKEN} or provide it in config.", + ) + + return MessengerAdapter( + MessengerAdapterConfig( + app_secret=_app_secret, + page_access_token=_page_access_token, + verify_token=_verify_token, + api_version=api_version, + logger=_logger, + user_name=user_name, + ) + ) diff --git a/src/chat_sdk/adapters/messenger/types.py b/src/chat_sdk/adapters/messenger/types.py index 6cd9d214..88a26cbd 100644 --- a/src/chat_sdk/adapters/messenger/types.py +++ b/src/chat_sdk/adapters/messenger/types.py @@ -52,15 +52,17 @@ class MessengerAdapterConfig: def resolved_app_secret(self) -> str | None: """App secret with the ``FACEBOOK_APP_SECRET`` env fallback.""" - return self.app_secret or os.environ.get(ENV_APP_SECRET) + # ``is not None`` (not truthy) mirrors upstream's ``??`` null-coalescing + # (index.ts:931) — an explicit config value wins over the env var. + return self.app_secret if self.app_secret is not None else os.environ.get(ENV_APP_SECRET) def resolved_page_access_token(self) -> str | None: """Page access token with the ``FACEBOOK_PAGE_ACCESS_TOKEN`` env fallback.""" - return self.page_access_token or os.environ.get(ENV_PAGE_ACCESS_TOKEN) + return self.page_access_token if self.page_access_token is not None else os.environ.get(ENV_PAGE_ACCESS_TOKEN) def resolved_verify_token(self) -> str | None: """Verify token with the ``FACEBOOK_VERIFY_TOKEN`` env fallback.""" - return self.verify_token or os.environ.get(ENV_VERIFY_TOKEN) + return self.verify_token if self.verify_token is not None else os.environ.get(ENV_VERIFY_TOKEN) # ============================================================================= diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index d1168ee1..56382aa4 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -22,6 +22,7 @@ from collections import OrderedDict from collections.abc import AsyncIterable, Awaitable, Callable from contextvars import ContextVar +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any, NoReturn, TypedDict, cast from urllib.parse import parse_qs, urlparse @@ -52,10 +53,11 @@ SlackBotToken, SlackBotTokenResolver, SlackInstallation, + SlackInstallationProvider, SlackThreadId, SlackWebhookVerifier, ) -from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_slack, resolve_emoji_from_slack +from chat_sdk.emoji import emoji_to_slack, resolve_emoji_from_slack from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.modals import ModalElement, OptionsLoadGroup, SelectOptionElement from chat_sdk.shared.adapter_utils import extract_card, extract_files @@ -207,6 +209,19 @@ def _make_slack_lookup_failed(user_id: str) -> SlackUserCacheEntry: # --------------------------------------------------------------------------- +@dataclass +class _InstallationInfo: + """Installation identity extracted from an interactive payload. + + ``installation_id`` is the team ID -- or the enterprise ID for + Enterprise Grid org-wide installs (``is_enterprise_install``). + """ + + installation_id: str + is_enterprise_install: bool + enterprise_id: str | None = None + + def _find_next_mention(text: str) -> int: """Find the next ``<@`` or ``<#`` mention in *text*.""" at_idx = text.find("<@") @@ -487,6 +502,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None: else: self._client_secret = None self._installation_key_prefix = config.installation_key_prefix or "slack:installation" + # External installation provider (e.g. Vercel Connect). When set, + # per-installation token lookups bypass internal StateAdapter storage. + self._installation_provider: SlackInstallationProvider | None = config.installation_provider # ``is not None`` (not truthiness) so an explicit ``encryption_key=""`` # is treated as "user explicitly opted out" and is NOT silently @@ -973,30 +991,79 @@ async def with_bot_token_async(self, token: str, fn: Callable[[], Awaitable[Any] # Private helpers - token resolution # ================================================================== - async def _resolve_token_for_team(self, team_id: str) -> RequestContext | None: - """Resolve the bot token for a team from the state adapter.""" + async def _resolve_token_for_team( + self, installation_id: str, is_enterprise_install: bool = False + ) -> RequestContext | None: + """Resolve the bot token for an installation. + + Checks the external installation provider first (e.g. Vercel + Connect); when no provider is configured, falls back to the + internal state adapter. + + ``installation_id`` is the ``team_id`` -- or the ``enterprise_id`` + for Enterprise Grid org-wide installs (``is_enterprise_install``). + """ try: - installation = await self.get_installation(team_id) + # Check external installation provider first (e.g. Vercel Connect) + if self._installation_provider is not None: + installation = await self._installation_provider.get_installation( + installation_id, is_enterprise_install + ) + if installation: + return RequestContext( + token=installation.bot_token, + bot_user_id=installation.bot_user_id, + ) + self._logger.warn( + "No installation found from provider", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) + return None + # Fall back to internal state adapter + installation = await self.get_installation(installation_id) if installation: return RequestContext( token=installation.bot_token, bot_user_id=installation.bot_user_id, ) - self._logger.warn("No installation found for team", {"teamId": team_id}) + self._logger.warn( + "No installation found for team", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return None except Exception as exc: - self._logger.error("Failed to resolve token for team", {"teamId": team_id, "error": exc}) + self._logger.error( + "Failed to resolve token for team", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install, "error": exc}, + ) return None - def _extract_team_id_from_interactive(self, body: str) -> str | None: - """Extract team_id from an interactive payload (form-urlencoded).""" + def _extract_installation_from_interactive(self, body: str) -> _InstallationInfo | None: + """Extract installation info from an interactive payload (form-urlencoded). + + For Enterprise Grid org-wide installs, the installation ID is the + enterprise ID; otherwise it is the team ID. + """ try: params = parse_qs(body) payload_str = params.get("payload", [None])[0] if not payload_str: return None payload = json.loads(payload_str) - return payload.get("team", {}).get("id") or payload.get("team_id") + is_enterprise_install = bool(payload.get("is_enterprise_install")) + enterprise = payload.get("enterprise") or {} + enterprise_id = enterprise.get("id") or payload.get("enterprise_id") or None + team = payload.get("team") or {} + team_id = team.get("id") or payload.get("team_id") or None + installation_id = enterprise_id if is_enterprise_install else team_id + + if not installation_id: + return None + return _InstallationInfo( + installation_id=installation_id, + is_enterprise_install=is_enterprise_install, + enterprise_id=enterprise_id, + ) except Exception: return None @@ -1359,24 +1426,47 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No # Slash command if "command" in params and "payload" not in params: - team_id = (params.get("team_id") or [None])[0] - if not self._is_single_workspace and team_id: - ctx = await self._resolve_token_for_team(team_id) - if ctx: - tok = self._request_context.set(ctx) - try: - return await self._handle_slash_command(params, options) - finally: - self._request_context.reset(tok) - self._logger.warn("Could not resolve token for slash command") + if not self._is_single_workspace: + # For Enterprise Grid org-wide installs, use enterprise_id; + # otherwise use team_id. + is_enterprise_install = (params.get("is_enterprise_install") or [None])[0] == "true" + enterprise_id = (params.get("enterprise_id") or [None])[0] + team_id = (params.get("team_id") or [None])[0] + installation_id = enterprise_id if is_enterprise_install else team_id + + if installation_id: + ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install) + if ctx: + ctx = replace( + ctx, + enterprise_id=enterprise_id, + is_enterprise_install=is_enterprise_install, + ) + tok = self._request_context.set(ctx) + try: + return await self._handle_slash_command(params, options) + finally: + self._request_context.reset(tok) + self._logger.warn( + "Could not resolve token for slash command", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return await self._handle_slash_command(params, options) # Interactive payload if not self._is_single_workspace: - team_id_interactive = self._extract_team_id_from_interactive(body) - if team_id_interactive: - ctx = await self._resolve_token_for_team(team_id_interactive) + installation_info = self._extract_installation_from_interactive(body) + if installation_info: + ctx = await self._resolve_token_for_team( + installation_info.installation_id, + installation_info.is_enterprise_install, + ) if ctx: + ctx = replace( + ctx, + enterprise_id=installation_info.enterprise_id, + is_enterprise_install=installation_info.is_enterprise_install, + ) tok = self._request_context.set(ctx) try: return await self._handle_interactive_payload(body, options) @@ -1406,15 +1496,27 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No # isolated -- the ContextVar change does not leak back to the caller # and does not need an explicit reset. if not self._is_single_workspace and payload.get("type") == "event_callback": - team_id_event = payload.get("team_id") - if team_id_event: - ctx = await self._resolve_token_for_team(team_id_event) + # For Enterprise Grid org-wide installs, use enterprise_id; + # otherwise use team_id. + is_enterprise_install = bool(payload.get("is_enterprise_install")) + installation_id = payload.get("enterprise_id") if is_enterprise_install else payload.get("team_id") + + if installation_id: + ctx = await self._resolve_token_for_team(installation_id, is_enterprise_install) if ctx: + ctx = replace( + ctx, + enterprise_id=payload.get("enterprise_id"), + is_enterprise_install=is_enterprise_install, + ) isolated = contextvars.copy_context() isolated.run(self._request_context.set, ctx) isolated.run(self._process_event_payload, payload, options) return {"body": "ok", "status": 200} - self._logger.warn("Could not resolve token for team", {"teamId": team_id_event}) + self._logger.warn( + "Could not resolve token for installation", + {"installationId": installation_id, "isEnterpriseInstall": is_enterprise_install}, + ) return {"body": "ok", "status": 200} # Single-workspace mode or fallback @@ -3201,13 +3303,17 @@ def _create_attachment(self, file: dict[str, Any], team_id: str | None = None) - the queue/debounce path JSON-serializes the message. """ url = file.get("url_private") - # Capture per-request token from the active webhook context so - # ``fetch_data`` can run later without being inside the ContextVar - # frame (e.g. after the message has been queued + rehydrated). + # Capture per-request context (token + Enterprise Grid info) from the + # active webhook context so ``fetch_data`` can run later without being + # inside the ContextVar frame (e.g. after the message has been queued + # + rehydrated), and ``rehydrate_attachment`` can resolve tokens + # through the same lookup logic on a different process invocation. # For single-workspace mode the default provider is re-resolved at # fetch time so dynamic ``bot_token`` resolvers honor rotation. ctx = self._request_context.get() ctx_token: str | None = ctx.token if ctx and ctx.token else None + ctx_enterprise_id: str | None = ctx.enterprise_id if ctx else None + ctx_is_enterprise_install: bool = bool(ctx.is_enterprise_install) if ctx else False mimetype = file.get("mimetype", "") att_type: str = "file" @@ -3227,6 +3333,12 @@ async def fetch_data() -> bytes: fetch_meta["url"] = url if team_id: fetch_meta["teamId"] = team_id + # Omit the Enterprise Grid keys entirely when absent (hazard #7: + # omitted keys, not serialized ``None``/false values). + if ctx_enterprise_id: + fetch_meta["enterpriseId"] = ctx_enterprise_id + if ctx_is_enterprise_install: + fetch_meta["isEnterpriseInstall"] = "true" return Attachment( type=att_type, # type: ignore[arg-type] @@ -3312,20 +3424,28 @@ def rehydrate_attachment(self, attachment: Attachment) -> Attachment: meta_url = meta.get("url") url = meta_url if meta_url is not None else attachment.url team_id = meta.get("teamId") + enterprise_id = meta.get("enterpriseId") + is_enterprise_install = meta.get("isEnterpriseInstall") == "true" if not url: return attachment adapter = self async def fetch_data() -> bytes: - if team_id: - installation = await adapter.get_installation(team_id) - if installation is None: + installation_id = enterprise_id if is_enterprise_install else team_id + if installation_id: + # Route through ``_resolve_token_for_team`` so + # ``installation_provider`` (when configured) is honored -- + # otherwise this falls back to internal state via + # ``get_installation``, matching the prior behavior. + ctx = await adapter._resolve_token_for_team(installation_id, is_enterprise_install) + if ctx is None: raise AuthenticationError( "slack", - f"Installation not found for team {team_id}", + f"Installation not found for " + f"{'enterprise' if is_enterprise_install else 'team'} {installation_id}", ) - token = installation.bot_token + token = ctx.token else: # Use the async resolver so a dynamic ``bot_token`` provider # is invoked at fetch time (rotation-safe). @@ -3354,35 +3474,6 @@ def _is_message_from_self(self, event: dict[str, Any]) -> bool: return True return bool(self._bot_id and event.get("bot_id") == self._bot_id) - # ================================================================== - # Table block rendering - # ================================================================== - - def _render_with_table_blocks(self, message: AdapterPostableMessage) -> dict[str, Any] | None: - """Try to render a message with native Slack table blocks. - - Returns ``{"text": ..., "blocks": ...}`` if the message contains tables, - ``None`` otherwise. - """ - ast: dict[str, Any] | None = None - if isinstance(message, dict): - ast = message.get("ast") # type: ignore[union-attr] - elif hasattr(message, "ast"): - ast = getattr(message, "ast", None) - elif hasattr(message, "markdown"): - # We don't have a full markdown->AST parser in Python; skip table blocks - return None - - if not ast: - return None - - blocks = self._format_converter.to_blocks_with_table(ast) - if not blocks: - return None - - fallback_text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") - return {"text": fallback_text, "blocks": blocks} - # ================================================================== # Post / Edit / Delete messages # ================================================================== @@ -3446,38 +3537,21 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> ), ) - # Table blocks - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts or None, - text=table_result["text"], - blocks=table_result["blocks"], - unfurl_links=False, - unfurl_media=False, - ) - return RawMessage( - id=result.get("ts", ""), - thread_id=thread_id, - raw=self._augment_raw_with_uploads( - result.data if hasattr(result, "data") else result, - uploaded_file_ids, - ), - ) - - # Regular text - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) self._logger.debug( "Slack API: chat.postMessage", - {"channel": channel, "threadTs": thread_ts, "textLength": len(text)}, + { + "channel": channel, + "threadTs": thread_ts, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, ) result = await client.chat_postMessage( channel=channel, thread_ts=thread_ts or None, - text=text, unfurl_links=False, unfurl_media=False, + **payload, ) return RawMessage( id=result.get("ts", ""), @@ -3547,22 +3621,16 @@ async def edit_message( raw=result.data if hasattr(result, "data") else result, ) - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_update( - channel=channel, - ts=message_id, - text=table_result["text"], - blocks=table_result["blocks"], - ) - return RawMessage( - id=result.get("ts", ""), - thread_id=thread_id, - raw=result.data if hasattr(result, "data") else result, - ) - - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") - result = await client.chat_update(channel=channel, ts=message_id, text=text) + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.update", + { + "channel": channel, + "messageId": message_id, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) + result = await client.chat_update(channel=channel, ts=message_id, **payload) return RawMessage( id=result.get("ts", ""), thread_id=thread_id, @@ -3864,28 +3932,21 @@ async def post_ephemeral( raw=result.data if hasattr(result, "data") else result, ) - table_result = self._render_with_table_blocks(message) - if table_result: - result = await client.chat_postEphemeral( - channel=channel, - thread_ts=thread_ts or None, - user=user_id, - text=table_result["text"], - blocks=table_result["blocks"], - ) - return EphemeralMessage( - id=result.get("message_ts", ""), - thread_id=thread_id, - used_fallback=False, - raw=result.data if hasattr(result, "data") else result, - ) - - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.postEphemeral", + { + "channel": channel, + "threadTs": thread_ts, + "userId": user_id, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) result = await client.chat_postEphemeral( channel=channel, thread_ts=thread_ts or None, user=user_id, - text=text, + **payload, ) return EphemeralMessage( id=result.get("message_ts", ""), @@ -3948,14 +4009,23 @@ async def schedule_message( unfurl_media=False, ) else: - text = convert_emoji_placeholders(self._format_converter.render_postable(message), "slack") + payload = self._format_converter.to_slack_payload(message) + self._logger.debug( + "Slack API: chat.scheduleMessage", + { + "channel": channel, + "threadTs": thread_ts, + "postAt": post_at_unix, + "payloadKey": "markdown_text" if "markdown_text" in payload else "text", + }, + ) result = await client.chat_scheduleMessage( channel=channel, thread_ts=thread_ts or None, post_at=post_at_unix, - text=text, unfurl_links=False, unfurl_media=False, + **payload, ) scheduled_message_id = result.get("scheduled_message_id", "") @@ -4425,7 +4495,10 @@ def parse_message(self, raw: dict[str, Any]) -> Message: return self._parse_slack_message_sync(event, thread_id) def render_formatted(self, content: FormattedContent) -> str: - """Render formatted content (AST) to Slack mrkdwn.""" + """Render formatted content (AST) to standard markdown. + + Slack now accepts markdown natively via ``markdown_text``. + """ return self._format_converter.from_ast(content) # ================================================================== @@ -4547,18 +4620,13 @@ async def _send_to_response_url( "blocks": card_to_block_kit(card), } else: - table_result = self._render_with_table_blocks(message) - if table_result: - payload = { - "replace_original": True, - "text": table_result["text"], - "blocks": table_result["blocks"], - } - else: - payload = { - "replace_original": True, - "text": convert_emoji_placeholders(self._format_converter.render_postable(message), "slack"), - } + # Slack rejects `markdown_text` on response_url payloads + # (`no_text`), so markdown/AST messages are rendered to + # Slack's legacy mrkdwn format for this surface. + payload = { + "replace_original": True, + "text": self._format_converter.to_response_url_text(message), + } if thread_ts: payload["thread_ts"] = thread_ts diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 4330e243..5bf74b33 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -1,14 +1,14 @@ -"""Slack-specific format conversion using AST-based parsing. +"""Slack format conversion. Port of markdown.ts from the Vercel Chat SDK Slack adapter. -Slack uses "mrkdwn" format which is similar but not identical to markdown: -- Bold: *text* (not **text**) -- Italic: _text_ (same) -- Strikethrough: ~text~ (not ~~text~~) -- Links: (not [text](url)) -- User mentions: <@U123> -- Channel mentions: <#C123|name> +Outgoing: Slack now natively renders markdown via the ``markdown_text`` field +on chat.postMessage / postEphemeral / update / scheduleMessage. We pass +markdown through there and let Slack handle it. Interactive ``response_url`` +payloads do not accept ``markdown_text``, so those still use Slack mrkdwn text. + +Incoming: Slack ``message`` events still deliver text as mrkdwn +(``*bold*``, ``<@U123>``, ````), so the ``to_ast`` parser stays. """ from __future__ import annotations @@ -16,33 +16,39 @@ import re from typing import Any -from chat_sdk.adapters.slack.cards import SlackBlock +from chat_sdk.emoji import convert_emoji_placeholders from chat_sdk.shared.base_format_converter import ( BaseFormatConverter, Content, Root, parse_markdown, + stringify_markdown, table_to_ascii, ) +# Match bare @mentions (e.g. "@george") to rewrite as Slack's `<@george>`. +# The lookbehind excludes `<` (already-formatted mentions like `<@U123>`) and +# any word character, so email addresses like `user@example.com` are left alone. +BARE_MENTION_REGEX = re.compile(r"(? str: - """Render an AST to Slack mrkdwn format.""" - return self._from_ast_with_node_converter(ast, self._node_to_mrkdwn) - - def to_ast(self, platform_text: str) -> Root: - """Parse Slack mrkdwn into an AST. + """Render an AST to standard markdown. - Converts Slack-specific syntax to standard markdown, then parses - with the shared parser. + Slack accepts this directly via ``markdown_text`` and the + ``markdown`` block. """ + return stringify_markdown(ast) + + def to_ast(self, platform_text: str) -> Root: + """Parse Slack mrkdwn into an AST. Used for incoming ``message`` events.""" markdown = platform_text # User mentions: <@U123|name> -> @name or <@U123> -> @U123 @@ -59,7 +65,7 @@ def to_ast(self, platform_text: str) -> Root: # Bare links: -> url markdown = re.sub(r"<(https?://[^<>]+)>", r"\1", markdown) - # Bold: *text* -> **text** (careful with emphasis) + # Bold: *text* -> **text** (Slack uses single * for bold) markdown = re.sub(r"(? ~~text~~ @@ -68,48 +74,73 @@ def to_ast(self, platform_text: str) -> Root: return parse_markdown(markdown) # ------------------------------------------------------------------------- - # Overrides + # Outgoing payload builders # ------------------------------------------------------------------------- - def render_postable(self, message: Any) -> str: - """Render a postable message to Slack mrkdwn string. + def to_slack_payload(self, message: Any) -> dict[str, str]: + """Build the Slack API payload fields for a message. + + - ``str`` / ``{"raw"}`` -> ``{"text"}`` (plain -- preserves literal + ``*``, ``_``, etc.) + - ``{"markdown"}`` / ``{"ast"}`` -> ``{"markdown_text"}`` (Slack + renders natively) + + Bare ``@user`` mentions are rewritten to ``<@user>`` and ``:emoji:`` + placeholders are normalized for Slack in all branches. - Supports str, ``{"raw": ...}``, ``{"markdown": ...}``, ``{"ast": ...}``, - and card types (``{"card": ...}`` / ``CardElement``). + Note: ``markdown_text`` has a 12,000 character limit; ``text`` allows + ~40,000. + Note: ``markdown_text`` is mutually exclusive with ``text`` and + ``blocks``. """ if isinstance(message, str): - return self._convert_mentions_to_slack(message) - if hasattr(message, "raw"): - return self._convert_mentions_to_slack(message.raw) + return {"text": self._finalize(message)} if isinstance(message, dict): if "raw" in message: - return self._convert_mentions_to_slack(message["raw"]) + return {"text": self._finalize(message["raw"])} if "markdown" in message: - return self.from_markdown(message["markdown"]) + return {"markdown_text": self._finalize(message["markdown"])} if "ast" in message: - return self.from_ast(message["ast"]) - if "card" in message: - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message["card"]) - if message.get("type") == "card": - from chat_sdk.cards import is_card_element - - if is_card_element(message): - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message) # type: ignore[arg-type] - return str(message) - # Dataclass-style objects - if hasattr(message, "markdown"): - return self.from_markdown(message.markdown) - if hasattr(message, "ast"): - return self.from_ast(message.ast) - if hasattr(message, "card"): - from chat_sdk.cards import card_to_fallback_text - - return card_to_fallback_text(message.card) - return str(message) + return {"markdown_text": self._finalize(stringify_markdown(message["ast"]))} + return {"text": ""} + # Dataclass / object-style messages + if getattr(message, "raw", None) is not None: + return {"text": self._finalize(message.raw)} + if getattr(message, "markdown", None) is not None: + return {"markdown_text": self._finalize(message.markdown)} + if getattr(message, "ast", None) is not None: + return {"markdown_text": self._finalize(stringify_markdown(message.ast))} + return {"text": ""} + + def to_response_url_text(self, message: Any) -> str: + """Build text for Slack response_url payloads. + + Slack rejects ``markdown_text`` on response_url (``no_text``), so + markdown/AST messages are rendered to Slack's legacy mrkdwn format + for this surface. + """ + if isinstance(message, str): + return self._finalize(message) + if isinstance(message, dict): + if "raw" in message: + return self._finalize(message["raw"]) + if "markdown" in message: + return convert_emoji_placeholders(self._ast_to_mrkdwn(parse_markdown(message["markdown"])), "slack") + if "ast" in message: + return convert_emoji_placeholders(self._ast_to_mrkdwn(message["ast"]), "slack") + return "" + # Dataclass / object-style messages + if getattr(message, "raw", None) is not None: + return self._finalize(message.raw) + if getattr(message, "markdown", None) is not None: + return convert_emoji_placeholders(self._ast_to_mrkdwn(parse_markdown(message.markdown)), "slack") + if getattr(message, "ast", None) is not None: + return convert_emoji_placeholders(self._ast_to_mrkdwn(message.ast), "slack") + return "" + + # ------------------------------------------------------------------------- + # Overrides + # ------------------------------------------------------------------------- def extract_plain_text(self, platform_text: str) -> str: """Extract plain text from Slack mrkdwn by stripping formatting.""" @@ -134,73 +165,17 @@ def extract_plain_text(self, platform_text: str) -> str: return text - # ------------------------------------------------------------------------- - # Slack table block support - # ------------------------------------------------------------------------- - - def to_blocks_with_table(self, ast: Root) -> list[SlackBlock] | None: - """Convert AST to Slack blocks, using native table block for the first table. - - Returns None if the AST contains no tables (caller should use regular text). - Slack allows at most one table block per message; additional tables use ASCII. - """ - if not isinstance(ast, dict): - return None - - children = ast.get("children", []) - has_table = any(isinstance(node, dict) and node.get("type") == "table" for node in children) - if not has_table: - return None - - blocks: list[SlackBlock] = [] - used_native_table = False - text_buffer: list[str] = [] - - def flush_text() -> None: - nonlocal text_buffer - if text_buffer: - text = "\n\n".join(text_buffer) - if text.strip(): - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": text}, - } - ) - text_buffer = [] - - for child in children: - node = child if isinstance(child, dict) else {} - if node.get("type") == "table": - flush_text() - if used_native_table: - # Additional tables fall back to ASCII in a code block - ascii_table = table_to_ascii(node) - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"```\n{ascii_table}\n```", - }, - } - ) - else: - blocks.append(self._mdast_table_to_slack_block(node)) - used_native_table = True - else: - text_buffer.append(self._node_to_mrkdwn(node)) - - flush_text() - return blocks - # ------------------------------------------------------------------------- # Private helpers # ------------------------------------------------------------------------- - def _convert_mentions_to_slack(self, text: str) -> str: - """Convert @mentions to Slack format: @name -> <@name>.""" - return re.sub(r"(?", text) + def _finalize(self, text: str) -> str: + """Rewrite bare @mentions and normalize emoji placeholders for Slack.""" + return convert_emoji_placeholders(BARE_MENTION_REGEX.sub(r"<@\1>", text), "slack") + + def _ast_to_mrkdwn(self, ast: Root) -> str: + """Render an AST to Slack's legacy mrkdwn (response_url surface only).""" + return self._from_ast_with_node_converter(ast, self._node_to_mrkdwn) def _node_to_mrkdwn(self, node: Content) -> str: """Convert a single AST node to Slack mrkdwn.""" @@ -215,7 +190,7 @@ def _node_to_mrkdwn(self, node: Content) -> str: if node_type == "text": value = node.get("value", "") - return re.sub(r"(?", value) + return BARE_MENTION_REGEX.sub(r"<@\1>", value) if node_type == "strong": content = "".join(self._node_to_mrkdwn(c) for c in children) @@ -250,7 +225,7 @@ def _node_to_mrkdwn(self, node: Content) -> str: return "\n".join(f"> {self._node_to_mrkdwn(c)}" for c in children) if node_type == "list": - return self._render_list(node, 0, self._node_to_mrkdwn, "\u2022") + return self._render_list(node, 0, self._node_to_mrkdwn, "•") if node_type == "break": return "\n" @@ -270,35 +245,3 @@ def _node_to_mrkdwn(self, node: Content) -> str: # Default fallback for any node with children return self._default_node_to_text(node, self._node_to_mrkdwn) - - def _mdast_table_to_slack_block(self, node: Content) -> SlackBlock: - """Convert a table AST node to a Slack table block. - - @see https://docs.slack.dev/reference/block-kit/blocks/table-block/ - """ - rows_data: list[list[dict[str, str]]] = [] - - for row in node.get("children", []): - cells = [] - for cell in row.get("children", []): - # Convert cell children to text, defaulting to a space if empty. - # Slack API requires table cell text to be at least 1 character. - # Use an explicit length check rather than a truthiness check to - # avoid substituting valid strings like "0". - raw_text = "".join(self._node_to_mrkdwn(c) for c in cell.get("children", [])) - text = raw_text if len(raw_text) > 0 else " " - cells.append({"type": "raw_text", "text": text}) - rows_data.append(cells) - - block: SlackBlock = {"type": "table", "rows": rows_data} - - align = node.get("align") - if align: - column_settings = [{"align": a or "left"} for a in align] - block["column_settings"] = column_settings - - return block - - -# Backwards compatibility alias -SlackMarkdownConverter = SlackFormatConverter diff --git a/src/chat_sdk/adapters/slack/types.py b/src/chat_sdk/adapters/slack/types.py index 42c2a35c..51762568 100644 --- a/src/chat_sdk/adapters/slack/types.py +++ b/src/chat_sdk/adapters/slack/types.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Literal, TypeAlias, TypedDict +from typing import Any, Literal, Protocol, TypeAlias, TypedDict from chat_sdk.logger import Logger @@ -75,8 +75,21 @@ class SlackAdapterConfig: # If provided, bot tokens stored via set_installation() will be encrypted at rest. encryption_key: str | None = None # Prefix for the state key used to store workspace installations. - # Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}``. + # Defaults to ``slack:installation``. The full key will be ``{prefix}:{team_id}`` + # (or ``{prefix}:{enterprise_id}`` for Enterprise Grid org-wide installs). installation_key_prefix: str = "slack:installation" + # External installation provider for multi-workspace apps using external + # token management (e.g. Vercel Connect). When set, the adapter bypasses + # internal StateAdapter storage for token lookups on incoming webhooks. + # + # For Enterprise Grid org-wide installs, ``installation_id`` will be the + # enterprise ID; otherwise it will be the team ID. + # + # Precedence: a configured default ``bot_token`` (single-workspace mode, + # static or resolver) still wins — per-installation resolution (and thus + # this provider) only runs in multi-workspace mode. See the resolver rows + # in docs/UPSTREAM_SYNC.md. + installation_provider: SlackInstallationProvider | None = None # Logger instance for error reporting. Defaults to ConsoleLogger. logger: Logger | None = None # Connection mode: ``"webhook"`` (default) or ``"socket"``. When set to @@ -128,6 +141,26 @@ class SlackInstallation: team_name: str | None = None +class SlackInstallationProvider(Protocol): + """External installation provider for multi-workspace token management. + + Implementations resolve a :class:`SlackInstallation` from an external + system (e.g. Vercel Connect) instead of the adapter's internal + StateAdapter storage. ``installation_id`` is the ``enterprise_id`` for + Enterprise Grid org-wide installs (``is_enterprise_install=True``), + otherwise the ``team_id``. Return ``None`` when no installation exists. + + The provider is read-only: ``set_installation`` / ``delete_installation`` + / ``handle_oauth_callback`` continue to write to the internal state + adapter, so callers using a provider should manage their own writes + through their external system. + """ + + def get_installation( + self, installation_id: str, is_enterprise_install: bool + ) -> Awaitable[SlackInstallation | None]: ... + + # ============================================================================= # Thread ID # ============================================================================= @@ -321,9 +354,13 @@ class SlackWebhookPayload(TypedDict, total=False): """Slack webhook payload envelope.""" challenge: str + # Enterprise ID for Enterprise Grid org-wide installs + enterprise_id: str event: Any # SlackEventUnion event_id: str event_time: int + # Whether this is an Enterprise Grid org-wide install + is_enterprise_install: bool # Whether this event occurred in an externally shared channel (Slack Connect) is_ext_shared_channel: bool team_id: str @@ -465,3 +502,7 @@ class RequestContext: token: str bot_user_id: str | None = None is_ext_shared_channel: bool | None = None + # Enterprise ID for Enterprise Grid org-wide installs + enterprise_id: str | None = None + # Whether this request came from an Enterprise Grid org-wide install + is_enterprise_install: bool | None = None diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index b58e47d6..5232a237 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -1700,7 +1700,7 @@ async def _close_stream_session( # from typing indicator to message bubble; if that send fails # the streaming UI may stay until Teams times the session out # client-side, but the recorded ``SentMessage`` and - # ``_message_history`` entry still match what the user saw. + # ``_thread_history`` entry still match what the user saw. self._logger.warn( "Teams stream final activity failed", {"threadId": thread_id, "error": str(exc)}, diff --git a/src/chat_sdk/adapters/telegram/adapter.py b/src/chat_sdk/adapters/telegram/adapter.py index 48651b4c..92efff71 100644 --- a/src/chat_sdk/adapters/telegram/adapter.py +++ b/src/chat_sdk/adapters/telegram/adapter.py @@ -594,7 +594,7 @@ def __init__(self, config: TelegramAdapterConfig | None = None) -> None: self._name: str = "telegram" self._lock_scope: LockScope = "channel" - self._persist_message_history: bool = True + self._persist_thread_history: bool = True self._bot_token: str = bot_token self._api_base_url: str = _trim_trailing_slashes( @@ -640,8 +640,8 @@ def lock_scope(self) -> LockScope: return self._lock_scope @property - def persist_message_history(self) -> bool: - return self._persist_message_history + def persist_thread_history(self) -> bool: + return self._persist_thread_history @property def bot_user_id(self) -> str | None: diff --git a/src/chat_sdk/adapters/whatsapp/adapter.py b/src/chat_sdk/adapters/whatsapp/adapter.py index 08b7489d..f64ad309 100644 --- a/src/chat_sdk/adapters/whatsapp/adapter.py +++ b/src/chat_sdk/adapters/whatsapp/adapter.py @@ -112,7 +112,7 @@ class WhatsAppAdapter: def __init__(self, config: WhatsAppAdapterConfig) -> None: self._name = "whatsapp" self._lock_scope: LockScope = "channel" - self._persist_message_history = True + self._persist_thread_history = True self._user_name = config.user_name self._access_token = config.access_token self._app_secret = config.app_secret @@ -137,8 +137,8 @@ def lock_scope(self) -> LockScope: return self._lock_scope @property - def persist_message_history(self) -> bool: - return self._persist_message_history + def persist_thread_history(self) -> bool: + return self._persist_thread_history @property def user_name(self) -> str: diff --git a/src/chat_sdk/ai/__init__.py b/src/chat_sdk/ai/__init__.py index d5a874dc..94d4a3ad 100644 --- a/src/chat_sdk/ai/__init__.py +++ b/src/chat_sdk/ai/__init__.py @@ -1,11 +1,12 @@ """AI SDK integration for the chat SDK. Python port of the ``chat/ai`` subpath. Mirrors the upstream structure where -``ai.ts`` was split into ``ai/messages.ts`` (and, in later PRs, ``ai/tools.ts``) -to make room for tool factories. +``ai.ts`` was split into ``ai/messages.ts`` and ``ai/tools.ts`` (plus the +``ai/tools/*`` helpers) to make room for the tool factory surface. -Re-exports everything the former ``chat_sdk.ai`` module exposed so existing -imports such as ``from chat_sdk.ai import to_ai_messages`` keep working. +Re-exports the message-conversion helpers and the tool factory + supporting +types so callers can do ``from chat_sdk.ai import to_ai_messages, +create_chat_tools`` regardless of how upstream splits the source files. """ from __future__ import annotations @@ -22,6 +23,36 @@ ToAiMessagesOptions, to_ai_messages, ) +from chat_sdk.ai.tools import ( + ApprovalConfig, + ChatBinding, + ChatTool, + ChatToolName, + ChatToolPreset, + ChatTools, + ChatToolsOptions, + ChatWriteToolName, + ToolOptions, + ToolOverrides, + add_reaction, + create_chat_tools, + delete_message, + edit_message, + fetch_channel_messages, + fetch_messages, + fetch_thread, + get_channel_info, + get_thread_participants, + get_user, + list_threads, + post_channel_message, + post_message, + remove_reaction, + send_direct_message, + start_typing, + subscribe_thread, + unsubscribe_thread, +) __all__ = [ "TEXT_MIME_PREFIXES", @@ -32,6 +63,34 @@ "AiMessagePart", "AiTextPart", "AiUserMessage", + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", "ToAiMessagesOptions", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", "to_ai_messages", ] diff --git a/src/chat_sdk/ai/tools.py b/src/chat_sdk/ai/tools.py new file mode 100644 index 00000000..ea84d362 --- /dev/null +++ b/src/chat_sdk/ai/tools.py @@ -0,0 +1,1133 @@ +"""Tool factory for exposing Chat SDK operations to AI agents. + +Python port of ``packages/chat/src/ai/tools.ts`` (and the supporting +``tools/{channels,messages,reactions,threads,users}.ts`` / ``types.ts`` +files) introduced by `vercel/chat#492`_. + +The TS implementation builds on the Vercel AI SDK's :func:`tool` helper and +``zod`` schemas. Python has no direct equivalent: there's no canonical AI +agent runtime in the standard library, and adding ``pydantic`` (or any other +runtime) would couple ``chat_sdk`` to a third-party schema validator. To +keep the surface framework-agnostic — and faithful to the upstream contract +that a tool is "a description + an input schema + an ``execute`` callable" — +each factory returns a plain :class:`ChatTool` dataclass holding: + +* ``description`` — the natural-language description shown to the model. +* ``input_schema`` — a JSON-Schema-shaped :class:`dict` describing the tool + inputs. Consumers that bind these tools into the Vercel AI SDK (via the + ``@ai-sdk/python``-style bridge) or any other agent runtime can feed this + dict directly to their schema layer. +* ``needs_approval`` — mirrors upstream's ``needsApproval`` flag for + human-in-the-loop write tools. Falsy for read-only tools, ``True`` by + default for writes. +* ``execute`` — an ``async`` callable taking a ``dict`` of validated + arguments and returning the tool result. + +The factory entry point :func:`create_chat_tools` mirrors upstream's +``createChatTools`` exactly: presets, ``require_approval`` config (bool or +per-tool mapping), per-tool ``overrides``, and the same set of protected +core fields that overrides cannot replace. + +.. _vercel/chat#492: https://github.com/vercel/chat/pull/492 +""" + +from __future__ import annotations + +import copy +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +from chat_sdk.chat import Chat +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.types import ( + Author, + FetchOptions, + ListThreadsOptions, + Message, + PostableMarkdown, + PostableRaw, +) + +# --------------------------------------------------------------------------- +# Tool dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class ChatTool: + """A Chat SDK tool exposed to an AI agent. + + Mirrors the runtime shape produced by upstream's ``tool({...})`` helper: + a ``description`` + an ``input_schema`` (JSON-Schema-shaped) + an + ``execute`` coroutine. ``needs_approval`` is ``True`` for write tools so + callers can gate execution behind human approval (matching upstream's + ``needsApproval`` flag). + + Additional upstream fields exposed via the ``overrides`` config — + ``title``, ``input_examples``, ``metadata``, ``provider_options``, + ``strict``, ``to_model_output``, ``on_input_available``, + ``on_input_delta``, ``on_input_start`` — are stored in :attr:`extras` + as a free-form dict so callers can forward them to whatever agent + runtime they bind these tools into. + """ + + description: str + input_schema: dict[str, Any] + execute: Callable[[dict[str, Any]], Awaitable[Any]] + needs_approval: bool | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Aliases mirroring upstream type names +# --------------------------------------------------------------------------- + +#: Alias for the ``Chat`` instance threaded through every tool factory. +#: Upstream types this as ``Chat``; in Python ``Chat`` is already +#: untyped at the adapter level so a plain alias is enough. +ChatBinding = Chat + + +@dataclass +class ToolOptions: + """Common options for write tools that may require approval before executing. + + Mirrors upstream's ``ToolOptions`` interface. + """ + + needs_approval: bool = True + + +#: Partial overrides for a single tool. Mirrors upstream's ``ToolOverrides``, +#: but ``input_schema``/``execute``/``output_schema`` etc. are filtered out +#: at apply-time (see :data:`_PROTECTED_TOOL_FIELDS`) so semantics stay +#: stable. +ToolOverrides = dict[str, Any] + + +# --------------------------------------------------------------------------- +# Tool names +# --------------------------------------------------------------------------- + +#: Every tool name produced by :func:`create_chat_tools`. Kept as a +#: ``Literal`` alias instead of an enum so the mapping types below can be +#: expressed naturally. +ChatToolName = Literal[ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "startTyping", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Names of every tool that mutates platform state. These default to +#: ``needs_approval=True`` and can be toggled via ``require_approval`` on +#: :func:`create_chat_tools`. +ChatWriteToolName = Literal[ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Whether write operations require user approval. +#: +#: - ``True`` — every write tool needs approval (default) +#: - ``False`` — no write tool needs approval +#: - ``dict`` — per-tool override; unspecified write tools default to +#: ``True`` +ApprovalConfig = bool | dict[str, bool] + +#: Predefined tool presets for common chat-agent use cases. +#: +#: - ``"reader"`` — read-only: fetch threads, messages, channel info, +#: users +#: - ``"messenger"`` — basic posting: post in thread/channel, DM, react, +#: typing +#: - ``"moderator"`` — full management: read + write + edit/delete + +#: subscriptions +ChatToolPreset = Literal["reader", "messenger", "moderator"] + + +_PRESET_TOOLS: dict[str, list[str]] = { + "reader": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + ], + "messenger": [ + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "addReaction", + "removeReaction", + "startTyping", + ], + "moderator": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + "startTyping", + ], +} + + +# Fields that overrides cannot replace. Mirrors upstream's +# ``PROTECTED_TOOL_FIELDS``. ``args``/``id``/``output_schema``/``type``/ +# ``supports_deferred_results`` come from upstream's AI SDK shape; they're +# included verbatim so consumers porting upstream ``overrides`` dicts get +# the same protection. +_PROTECTED_TOOL_FIELDS: frozenset[str] = frozenset( + { + "args", + "execute", + "id", + "input_schema", + "inputSchema", + "output_schema", + "outputSchema", + "supports_deferred_results", + "supportsDeferredResults", + "type", + } +) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _project_message(message: Message) -> dict[str, Any]: + """Flatten a :class:`~chat_sdk.types.Message` for model consumption. + + Mirrors upstream's ``projectMessage`` helper. Field names use camelCase + so that JSON dumps of the returned dicts match the upstream wire shape; + Python callers who want snake_case can rename downstream. + """ + return { + "id": message.id, + "threadId": message.thread_id, + "text": message.text, + "author": { + "userId": message.author.user_id, + "userName": message.author.user_name, + "fullName": message.author.full_name, + "isBot": message.author.is_bot, + "isMe": message.author.is_me, + }, + "dateSent": (message.metadata.date_sent.isoformat() if message.metadata.date_sent else None), + "edited": message.metadata.edited, + "isMention": getattr(message, "is_mention", False), + "attachments": [ + { + "type": att.type, + "name": att.name, + "mimeType": att.mime_type, + "url": att.url, + } + for att in (message.attachments if message.attachments is not None else []) + ], + } + + +def _project_author(author: Author) -> dict[str, Any]: + return { + "userId": author.user_id, + "userName": author.user_name, + "fullName": author.full_name, + "isBot": author.is_bot, + } + + +# ``message`` arg shape — one of: plain string, ``{"markdown": "..."}``, +# ``{"raw": "..."}``. Mirrors upstream's ``POSTABLE_INPUT`` union. +_POSTABLE_INPUT_SCHEMA: dict[str, Any] = { + "oneOf": [ + {"type": "string", "description": "Plain text body"}, + { + "type": "object", + "properties": {"markdown": {"type": "string"}}, + "required": ["markdown"], + "additionalProperties": False, + "description": "Markdown body, converted to the platform's native format", + }, + { + "type": "object", + "properties": {"raw": {"type": "string"}}, + "required": ["raw"], + "additionalProperties": False, + "description": "Raw body, passed through to the platform untouched", + }, + ], + "description": "Message body", +} + + +def _to_postable(message: Any) -> Any: + """Translate a tool-input ``message`` value to the SDK's postable form. + + Accepts a plain string, ``{"markdown": "..."}``, or ``{"raw": "..."}``. + The string and ``{"raw": ...}`` cases pass through unchanged so the + adapter can decide how to handle them; ``{"markdown": ...}`` is wrapped + in a :class:`~chat_sdk.types.PostableMarkdown` so the SDK's markdown + rendering path runs. + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + if "markdown" in message: + return PostableMarkdown(markdown=message["markdown"]) + if "raw" in message: + return PostableRaw(raw=message["raw"]) + # Fall through — pass anything else through unchanged so adapters that + # accept their own postable shapes (e.g. a card) still work. + return message + + +# --------------------------------------------------------------------------- +# channels.ts +# --------------------------------------------------------------------------- + + +def get_channel_info(chat: ChatBinding) -> ChatTool: + """Fetch metadata for a channel (name, member count, DM status, etc.).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + info = await channel.fetch_metadata() + return { + "id": info.id, + "name": info.name, + "isDM": info.is_dm if info.is_dm is not None else False, + "memberCount": info.member_count, + "channelVisibility": info.channel_visibility, + } + + return ChatTool( + description=( + "Fetch metadata for a channel: name, member count, DM status, " + "visibility, etc. Use to identify a channel before posting." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# messages.ts +# --------------------------------------------------------------------------- + + +def post_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a message inside an existing thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + sent = await thread.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a message inside an existing thread. Use this to reply within a " + "conversation the bot already has context for. The threadId is the " + "full id (e.g. 'slack:C123:1234567890.123456')." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def post_channel_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a top-level channel message (not threaded under another message).""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + sent = await channel.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a top-level message to a channel (not threaded under an existing " + "message). The channelId is the full id (e.g. 'slack:C123ABC')." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["channelId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def send_direct_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Open (or reuse) a 1:1 DM with a user and post in it.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + dm = await chat.open_dm(args["userId"]) + sent = await dm.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Open (or reuse) a 1:1 direct-message conversation with a user and post " + "a message in it. The userId format is platform-specific (e.g. 'U123456' " + "for Slack, 'users/123' for Google Chat)." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["userId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def edit_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Edit a previously posted message in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + result = await thread.adapter.edit_message( + args["threadId"], + args["messageId"], + _to_postable(args["message"]), + ) + return {"messageId": result.id, "threadId": result.thread_id} + + return ChatTool( + description=( + "Edit a previously posted message in a thread. Replaces the existing " + "message body. Only messages the bot itself authored can be edited on " + "most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to edit", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "messageId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def delete_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Delete a message from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.delete_message(args["threadId"], args["messageId"]) + return { + "deleted": True, + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Delete a message from a thread. Only messages the bot itself authored can be deleted on most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to delete", + }, + }, + "required": ["threadId", "messageId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# reactions.ts +# --------------------------------------------------------------------------- + + +def add_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Add an emoji reaction to a specific message.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.add_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "added": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Add an emoji reaction to a specific message. Use a well-known emoji " + "name (e.g. 'thumbs_up', 'heart', 'check') or a platform-native shorthand." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to react to", + }, + "emoji": { + "type": "string", + "description": ("Emoji name or platform shortcode (e.g. 'thumbs_up', 'white_check_mark')"), + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def remove_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Remove an emoji reaction the bot previously added.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.remove_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "removed": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description="Remove an emoji reaction the bot previously added to a message.", + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to remove the reaction from", + }, + "emoji": { + "type": "string", + "description": "Emoji name or platform shortcode previously added by the bot", + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# threads.ts +# --------------------------------------------------------------------------- + + +_FETCH_DIRECTION_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["forward", "backward"], + "default": "backward", +} + + +def fetch_messages(chat: ChatBinding) -> ChatTool: + """Fetch recent messages from a thread, oldest-first within the page.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + result = await thread.adapter.fetch_messages( + args["threadId"], + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch recent messages from a thread, ordered chronologically (oldest " + "first within the page). Use to read the conversation before responding." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum number of messages to fetch", + }, + "cursor": { + "type": "string", + "description": "Pagination cursor from a previous fetchMessages call", + }, + "direction": { + **copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + "description": ( + "'backward' (default) returns the most recent messages; 'forward' iterates from the oldest" + ), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_channel_messages(chat: ChatBinding) -> ChatTool: + """Fetch top-level messages in a channel (not thread replies).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + fetch_method = getattr(adapter, "fetch_channel_messages", None) if adapter is not None else None + if fetch_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + try: + result = await fetch_method( + channel_id, + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') from exc + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch top-level messages in a channel (not thread replies). Returns " + "messages in chronological order within the page." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + "direction": copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_thread(chat: ChatBinding) -> ChatTool: + """Fetch metadata about a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + info = await thread.adapter.fetch_thread(args["threadId"]) + return { + "id": info.id, + "channelId": info.channel_id, + "channelName": info.channel_name, + "channelVisibility": info.channel_visibility, + "isDM": info.is_dm if info.is_dm is not None else False, + } + + return ChatTool( + description=("Fetch metadata about a thread (channel id, channel name, visibility, DM status, etc)."), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def list_threads(chat: ChatBinding) -> ChatTool: + """List recent threads in a channel.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + list_method = getattr(adapter, "list_threads", None) if adapter is not None else None + if list_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + try: + result = await list_method(channel_id, options=ListThreadsOptions(limit=limit, cursor=cursor)) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') from exc + return { + "threads": [ + { + "id": t.id, + "replyCount": t.reply_count, + "lastReplyAt": t.last_reply_at.isoformat() if t.last_reply_at else None, + "rootMessage": _project_message(t.root_message), + } + for t in result.threads + ], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "List recent threads in a channel. Returns lightweight summaries with the root message of each thread." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def get_thread_participants(chat: ChatBinding) -> ChatTool: + """Return the unique non-bot participants in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + participants = await thread.get_participants() + return {"participants": [_project_author(p) for p in participants]} + + return ChatTool( + description=( + "Return the unique non-bot participants in a thread. Useful for " + "deciding whether to subscribe (1:1) or stay quiet (group)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def subscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Subscribe to all future messages in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.subscribe() + return {"subscribed": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Subscribe to all future messages in a thread. After subscribing, the " + "bot will receive every message in this thread (not just @mentions)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to subscribe to", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def unsubscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Unsubscribe from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.unsubscribe() + return {"subscribed": False, "threadId": args["threadId"]} + + return ChatTool( + description=("Unsubscribe from a thread. The bot will stop receiving non-mention messages in this thread."), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to unsubscribe from", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def start_typing(chat: ChatBinding) -> ChatTool: + """Show a typing indicator in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.start_typing(args.get("status")) + return {"typing": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Show a typing indicator in a thread. Use this when starting a " + "long-running operation so users know the bot is working." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "status": { + "type": "string", + "description": ("Optional human-readable status (some platforms display this, others ignore it)"), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# users.ts +# --------------------------------------------------------------------------- + + +def get_user(chat: ChatBinding) -> ChatTool: + """Look up profile information about a user by platform-specific id.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any] | None: + user = await chat.get_user(args["userId"]) + if not user: + return None + return { + "userId": user.user_id, + "userName": user.user_name, + "fullName": user.full_name, + "email": user.email, + "isBot": user.is_bot, + "avatarUrl": user.avatar_url, + } + + return ChatTool( + description=( + "Look up profile information about a user by their platform-specific id " + "(e.g. 'U123456' for Slack, '29:...' for Teams, 'users/123' for Google " + "Chat). Returns null if the user is unknown." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + }, + "required": ["userId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +def _resolve_approval(tool_name: str, config: ApprovalConfig) -> bool: + if isinstance(config, bool): + return config + return config.get(tool_name, True) + + +def _resolve_preset_tools(preset: ChatToolPreset | list[ChatToolPreset]) -> set[str]: + presets: list[str] = [preset] if isinstance(preset, str) else list(preset) + tools: set[str] = set() + for p in presets: + if p not in _PRESET_TOOLS: + raise ChatError(f'Unknown preset: "{p}"') + for t in _PRESET_TOOLS[p]: + tools.add(t) + return tools + + +def _apply_overrides(tool: ChatTool, overrides: ToolOverrides | None) -> ChatTool: + """Apply tool overrides while blocking attempts to replace core fields. + + Core fields — ``execute``, ``input_schema``, etc. — are filtered out so + that overrides can never break tool semantics. ``description`` and + ``needs_approval`` are first-class fields on :class:`ChatTool` and are + applied directly; everything else is stashed in :attr:`ChatTool.extras` + for downstream agent runtimes to pick up. + """ + if not overrides: + return tool + + safe = {k: v for k, v in overrides.items() if k not in _PROTECTED_TOOL_FIELDS} + + description = safe.pop("description", tool.description) + needs_approval = safe.pop("needs_approval", safe.pop("needsApproval", tool.needs_approval)) + + extras = {**tool.extras, **safe} + return replace( + tool, + description=description, + needs_approval=needs_approval, + extras=extras, + ) + + +@dataclass +class ChatToolsOptions: + """Options for :func:`create_chat_tools`. Mirrors upstream's ``ChatToolsOptions``.""" + + chat: ChatBinding + overrides: dict[str, ToolOverrides] | None = None + preset: ChatToolPreset | list[ChatToolPreset] | None = None + require_approval: ApprovalConfig = True + + +def create_chat_tools( + chat: ChatBinding | None = None, + *, + preset: ChatToolPreset | list[ChatToolPreset] | None = None, + require_approval: ApprovalConfig = True, + overrides: dict[str, ToolOverrides] | None = None, +) -> dict[str, ChatTool]: + """Create a set of Chat SDK tools for an AI agent. + + Mirrors upstream's ``createChatTools`` from ``chat/ai``: returns a dict + keyed by ``ChatToolName`` (camelCase, matching upstream so consumers + that bind to AI runtimes get the same tool ids). + + Each entry is built lazily so a preset filter skips both the + ``approval`` lookup and the underlying tool construction for tools the + agent will never see — same optimization as upstream. + + Parameters mirror upstream 1:1: + + chat: + The :class:`~chat_sdk.chat.Chat` instance the tools dispatch + operations against. **Required.** + preset: + Optional preset or list of presets to scope the returned toolset. + Omit (or pass ``None``) to get every tool. + require_approval: + ``True`` (default) to require human approval for every write tool; + ``False`` to disable approval globally; or a per-tool ``dict`` + where unspecified write tools default to ``True``. + overrides: + Per-tool overrides. Mirrors upstream's behaviour: core fields + cannot be overridden (see :data:`_PROTECTED_TOOL_FIELDS`). + """ + if chat is None: + raise ChatError( + "createChatTools requires a `chat` instance. Pass your `Chat({ ... })` instance as the `chat` option." + ) + + allowed: set[str] | None = _resolve_preset_tools(preset) if preset is not None else None + + def _approval(name: str) -> ToolOptions: + return ToolOptions(needs_approval=_resolve_approval(name, require_approval)) + + factories: dict[str, Callable[[], ChatTool]] = { + "fetchMessages": lambda: fetch_messages(chat), + "fetchChannelMessages": lambda: fetch_channel_messages(chat), + "fetchThread": lambda: fetch_thread(chat), + "listThreads": lambda: list_threads(chat), + "getThreadParticipants": lambda: get_thread_participants(chat), + "getChannelInfo": lambda: get_channel_info(chat), + "getUser": lambda: get_user(chat), + "startTyping": lambda: start_typing(chat), + "postMessage": lambda: post_message(chat, _approval("postMessage")), + "postChannelMessage": lambda: post_channel_message(chat, _approval("postChannelMessage")), + "sendDirectMessage": lambda: send_direct_message(chat, _approval("sendDirectMessage")), + "editMessage": lambda: edit_message(chat, _approval("editMessage")), + "deleteMessage": lambda: delete_message(chat, _approval("deleteMessage")), + "addReaction": lambda: add_reaction(chat, _approval("addReaction")), + "removeReaction": lambda: remove_reaction(chat, _approval("removeReaction")), + "subscribeThread": lambda: subscribe_thread(chat, _approval("subscribeThread")), + "unsubscribeThread": lambda: unsubscribe_thread(chat, _approval("unsubscribeThread")), + } + + result: dict[str, ChatTool] = {} + overrides_map = overrides if overrides is not None else {} + for name, build in factories.items(): + if allowed is not None and name not in allowed: + continue + built = build() + result[name] = _apply_overrides(built, overrides_map.get(name)) + return result + + +#: Alias matching upstream's ``ChatTools`` — the shape returned by +#: :func:`create_chat_tools`. Kept as an alias rather than a distinct type +#: so consumers can treat the result as a plain dict. +ChatTools = dict[str, ChatTool] + + +__all__ = [ + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", +] diff --git a/src/chat_sdk/callback_url.py b/src/chat_sdk/callback_url.py new file mode 100644 index 00000000..c573928d --- /dev/null +++ b/src/chat_sdk/callback_url.py @@ -0,0 +1,229 @@ +"""Callback URL handling for buttons and modals. + +Python port of callback-url.ts (vercel/chat#454). + +When a button (or modal) carries a ``callback_url``, the SDK stores the URL +in the state adapter under a short random token at post time and rewrites +the button's ``value`` to an encoded token (``__cb:``). When the +button is clicked, :meth:`Chat.process_action` decodes the token, restores +the original value for handlers, and POSTs the action payload to the stored +URL. Modal callback URLs are stored in the modal context and POSTed on +submit. +""" + +from __future__ import annotations + +import json +import secrets +from dataclasses import dataclass +from typing import Any + +from chat_sdk.cards import ActionsElement, ButtonElement, CardChild, CardElement +from chat_sdk.errors import ChatError +from chat_sdk.types import StateAdapter + +CALLBACK_TOKEN_PREFIX = "__cb:" +CALLBACK_CACHE_KEY_PREFIX = "chat:callback:" +CALLBACK_TTL_MS = 30 * 24 * 60 * 60 * 1000 # 30 days + + +# --------------------------------------------------------------------------- +# Result types (TS uses inline object literals; port rule #9: typed objects) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DecodedCallbackValue: + """Result of :func:`decode_callback_value`.""" + + callback_token: str | None + + +@dataclass(frozen=True) +class ResolvedCallback: + """A stored callback resolved from the state adapter.""" + + url: str + original_value: str | None = None + + +@dataclass(frozen=True) +class CallbackPostResult: + """Result of :func:`post_to_callback_url`.""" + + error: Exception | None = None + status: int | None = None + + +# --------------------------------------------------------------------------- +# Token encoding +# --------------------------------------------------------------------------- + + +def encode_callback_value(token: str) -> str: + """Encode a callback token into a button value.""" + return f"{CALLBACK_TOKEN_PREFIX}{token}" + + +def decode_callback_value(value: str | None) -> DecodedCallbackValue: + """Extract the callback token from an encoded button value, if any.""" + if not value or not value.startswith(CALLBACK_TOKEN_PREFIX): + return DecodedCallbackValue(callback_token=None) + return DecodedCallbackValue(callback_token=value[len(CALLBACK_TOKEN_PREFIX) :]) + + +def _generate_token() -> str: + # Upstream: crypto.randomUUID().replace(/-/g, "").slice(0, 16). + # Port rule #12: the token gates where action payloads are POSTed, so + # use `secrets` (16 hex chars = 64 bits, same shape as upstream). + return secrets.token_hex(8) + + +# --------------------------------------------------------------------------- +# Card processing (runs at post time) +# --------------------------------------------------------------------------- + + +async def _process_actions_element( + actions: ActionsElement, + state_adapter: StateAdapter, +) -> ActionsElement: + children: list[Any] = [] + for el in actions.get("children", []): + if not isinstance(el, dict) or el.get("type") != "button" or not el.get("callback_url"): + children.append(el) + continue + + token = _generate_token() + # Stored shape matches the TS SDK (`{url, originalValue}`) so state + # written by either SDK resolves in both. `originalValue` is omitted + # (not None) when the button has no value — hazard #7. + stored: dict[str, Any] = {"url": el["callback_url"]} + original_value = el.get("value") + if original_value is not None: + stored["originalValue"] = original_value + await state_adapter.set(f"{CALLBACK_CACHE_KEY_PREFIX}{token}", stored, CALLBACK_TTL_MS) + + # Rebuild the button without `callback_url` (mirrors upstream's + # explicit-key copy; keys absent on the source stay absent). + processed: ButtonElement = {"type": "button", "id": el["id"], "label": el["label"]} + if "style" in el: + processed["style"] = el["style"] + if "disabled" in el: + processed["disabled"] = el["disabled"] + processed["value"] = encode_callback_value(token) + if "action_type" in el: + processed["action_type"] = el["action_type"] + children.append(processed) + return {"type": "actions", "children": children} + + +def _has_callback_buttons(children: list[CardChild]) -> bool: + for child in children: + if not isinstance(child, dict): + continue + if child.get("type") == "actions": + for el in child.get("children", []): + if isinstance(el, dict) and el.get("type") == "button" and el.get("callback_url"): + return True + if child.get("type") == "section" and "children" in child and _has_callback_buttons(child["children"]): + return True + return False + + +async def _process_children( + children: list[CardChild], + state_adapter: StateAdapter, +) -> list[CardChild]: + result: list[CardChild] = [] + for child in children: + if isinstance(child, dict) and child.get("type") == "actions": + result.append(await _process_actions_element(child, state_adapter)) # type: ignore[arg-type] + elif isinstance(child, dict) and child.get("type") == "section" and "children" in child: + result.append({**child, "children": await _process_children(child["children"], state_adapter)}) # type: ignore[misc] + else: + result.append(child) + return result + + +async def process_card_callback_urls( + card: CardElement, + state_adapter: StateAdapter, +) -> CardElement: + """Replace ``callback_url`` buttons with encoded token values. + + Returns the *same* card object when no button carries a callback URL; + otherwise returns a new card (the original is never mutated). + """ + if not _has_callback_buttons(card.get("children", [])): + return card + + return {**card, "children": await _process_children(card.get("children", []), state_adapter)} + + +# --------------------------------------------------------------------------- +# Resolution + POST (runs at click/submit time) +# --------------------------------------------------------------------------- + + +async def resolve_callback_url( + token: str, + state_adapter: StateAdapter, +) -> ResolvedCallback | None: + """Look up a stored callback by token. Returns ``None`` when unknown.""" + stored = await state_adapter.get(f"{CALLBACK_CACHE_KEY_PREFIX}{token}") + if not stored: + return None + if isinstance(stored, str): + # Legacy format: the URL was stored as a bare string. + return ResolvedCallback(url=stored) + original_value = stored["originalValue"] if "originalValue" in stored else stored.get("original_value") + return ResolvedCallback(url=stored.get("url"), original_value=original_value) + + +async def _fetch(url: str, *, method: str, headers: dict[str, str], body: str) -> tuple[int, str]: + """POST ``body`` to ``url`` and return ``(status, text)``. + + Thin seam over aiohttp so tests can stub the network the way upstream + stubs global ``fetch``. aiohttp is an optional dependency, so it is + imported lazily (hazard #10); only http(s) URLs are supported, matching + the WHATWG ``fetch`` upstream relies on. + """ + import aiohttp + + async with ( + aiohttp.ClientSession() as session, + session.request(method, url, data=body.encode("utf-8"), headers=headers) as response, + ): + try: + text = await response.text() + except Exception: + # Mirrors upstream's `response.text().catch(() => "")`. + text = "" + return response.status, text + + +async def post_to_callback_url( + callback_url: str, + payload: dict[str, Any], +) -> CallbackPostResult: + """POST a JSON payload to a callback URL. + + Never raises: network and HTTP errors are returned in + :class:`CallbackPostResult` for the caller to log. + """ + try: + status, text = await _fetch( + callback_url, + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps(payload), + ) + if not 200 <= status < 300: + return CallbackPostResult( + error=ChatError(f"Callback URL returned {status}: {text}"), + status=status, + ) + return CallbackPostResult(status=status) + except Exception as error: + return CallbackPostResult(error=error) diff --git a/src/chat_sdk/cards.py b/src/chat_sdk/cards.py index f7b96b3f..1939b916 100644 --- a/src/chat_sdk/cards.py +++ b/src/chat_sdk/cards.py @@ -29,6 +29,8 @@ class ButtonElement(_ButtonRequired, total=False): value: str disabled: bool action_type: Literal["action", "modal"] | None + # URL to POST action data to when this button is clicked + callback_url: str class _LinkButtonRequired(TypedDict): @@ -256,6 +258,7 @@ def Button( value: str | None = None, disabled: bool | None = None, action_type: Literal["action", "modal"] | None = None, + callback_url: str | None = None, ) -> ButtonElement: """Create a Button element. @@ -264,6 +267,7 @@ def Button( Button(id="submit", label="Submit", style="primary") Button(id="delete", label="Delete", style="danger", value="item-123") Button(id="open", label="Open", action_type="modal") + Button(id="approve", label="Approve", callback_url="https://example.com/hook") """ element: ButtonElement = {"type": "button", "id": id, "label": label} if style is not None: @@ -274,6 +278,8 @@ def Button( element["disabled"] = disabled if action_type is not None: element["action_type"] = action_type + if callback_url is not None: + element["callback_url"] = callback_url return element diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 29811374..3bf57130 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -8,10 +8,11 @@ from __future__ import annotations from collections.abc import AsyncIterator -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Any +from chat_sdk.callback_url import process_card_callback_urls from chat_sdk.errors import ChatNotImplementedError from chat_sdk.plan import is_postable_object, post_postable_object from chat_sdk.thread import ( @@ -36,6 +37,7 @@ FetchOptions, Message, MessageMetadata, + PostableCard, PostableMarkdown, PostableMessage, PostEphemeralOptions, @@ -83,7 +85,7 @@ class _ChannelImplConfigWithAdapter: state_adapter: StateAdapter channel_visibility: ChannelVisibility = "unknown" is_dm: bool = False - message_history: Any = None + thread_history: Any = None @dataclass @@ -122,15 +124,15 @@ def __init__(self, config: _ChannelImplConfig) -> None: self._adapter: Adapter | None = None self._adapter_name: str | None = config.adapter_name self._state_adapter_instance: StateAdapter | None = None - self._message_history: Any = None + self._thread_history: Any = None else: # _ChannelImplConfigWithAdapter, _ChannelImplConfigForThread, # or _ChannelImplConfigForChat (from chat.py) -- all have - # adapter, state_adapter, and optional message_history attrs. + # adapter, state_adapter, and optional thread_history attrs. self._adapter = config.adapter # type: ignore[union-attr] self._adapter_name = None self._state_adapter_instance = config.state_adapter # type: ignore[union-attr] - self._message_history = getattr(config, "message_history", None) + self._thread_history = getattr(config, "thread_history", None) # -- Properties ---------------------------------------------------------- @@ -206,7 +208,7 @@ async def messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter channel_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -227,8 +229,8 @@ async def messages(self) -> AsyncIterator[Message]: cursor = result.next_cursor # Fallback to cached history - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(channel_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(channel_id) for msg in reversed(cached): yield msg @@ -285,10 +287,10 @@ async def post( # Handle PostableObject (e.g. Plan) if is_postable_object(message): raw = await self._handle_postable_object(message) - if self._message_history is not None and raw is not None: + if self._thread_history is not None and raw is not None: fallback = message.get_fallback_text() if hasattr(message, "get_fallback_text") else "" sent = self._create_sent_message(raw.id, PostableMarkdown(markdown=fallback), raw.thread_id) - await self._message_history.append(self._id, _to_message(sent)) + await self._thread_history.append(self._id, _to_message(sent)) return message if _is_async_iterable(message): @@ -299,6 +301,7 @@ async def post( return await self._post_single_message(PostableMarkdown(markdown=accumulated)) postable: AdapterPostableMessage = message # type: ignore[assignment] + postable = await self._process_callback_urls(postable) return await self._post_single_message(postable) async def _post_single_message( @@ -312,8 +315,8 @@ async def _post_single_message( sent = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) return sent @@ -337,6 +340,8 @@ async def post_ephemeral( """Post an ephemeral message visible only to the specified user.""" user_id = user if isinstance(user, str) else user.user_id + message = await self._process_callback_urls(message) # type: ignore[assignment] + if hasattr(self.adapter, "post_ephemeral") and self.adapter.post_ephemeral: # type: ignore[union-attr] return await self.adapter.post_ephemeral(self._id, user_id, message) # type: ignore[union-attr] @@ -362,6 +367,8 @@ async def schedule( post_at: datetime, ) -> ScheduledMessage: """Schedule a message for future delivery.""" + message = await self._process_callback_urls(message) # type: ignore[assignment] + if not hasattr(self.adapter, "schedule_message") or not self.adapter.schedule_message: # type: ignore[union-attr] raise ChatNotImplementedError( self.adapter.name, @@ -369,6 +376,28 @@ async def schedule( ) return await self.adapter.schedule_message(self._id, message, {"post_at": post_at}) # type: ignore[union-attr] + async def _process_callback_urls( + self, + postable: str | AdapterPostableMessage, + ) -> str | AdapterPostableMessage: + """Encode ``callback_url`` buttons in outgoing cards (vercel/chat#454).""" + if isinstance(postable, str): + return postable + + if isinstance(postable, dict) and postable.get("type") == "card": + return await process_card_callback_urls(postable, self._state_adapter) + + if ( + isinstance(postable, PostableCard) + and isinstance(postable.card, dict) + and postable.card.get("type") == "card" + ): + processed = await process_card_callback_urls(postable.card, self._state_adapter) + if processed is not postable.card: + return replace(postable, card=processed) + + return postable + # -- Typing indicator ---------------------------------------------------- async def start_typing(self, status: str | None = None) -> None: @@ -439,11 +468,11 @@ def from_json( # Validation passed — safe to invalidate. # Both `_state_adapter_instance` (old chat's state backend) - # and `_message_history` (old chat's cache) would route to + # and `_thread_history` (old chat's cache) would route to # the previous context otherwise. if adapter is not None or chat is not None: channel._state_adapter_instance = None - channel._message_history = None + channel._thread_history = None else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall @@ -519,6 +548,7 @@ def _create_sent_message( plain_text, formatted, attachments = _extract_message_content(postable) async def _edit(new_content: Any) -> SentMessage: + new_content = await channel_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return channel_impl._create_sent_message(message_id, new_content) diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 31533089..1339275f 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -18,6 +18,11 @@ from datetime import datetime, timezone from typing import Any +from chat_sdk.callback_url import ( + decode_callback_value, + post_to_callback_url, + resolve_callback_url, +) from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError from chat_sdk.logger import ConsoleLogger, Logger @@ -29,6 +34,7 @@ has_chat_singleton, set_chat_singleton, ) +from chat_sdk.transcripts import TranscriptsApiImpl from chat_sdk.types import ( ActionEvent, Adapter, @@ -43,6 +49,8 @@ ConcurrencyConfig, ConcurrencyStrategy, EmojiValue, + IdentityContext, + IdentityResolver, Lock, LockScope, LockScopeContext, @@ -60,6 +68,7 @@ ReactionEvent, SlashCommandEvent, StateAdapter, + TranscriptsApi, UserInfo, WebhookOptions, _parse_iso, @@ -172,17 +181,19 @@ def __init__(self, commands: list[str], handler: SlashCommandHandler) -> None: class _StoredModalContext: - __slots__ = ("thread", "message", "channel") + __slots__ = ("thread", "message", "channel", "callback_url") def __init__( self, thread: dict[str, Any] | None = None, message: dict[str, Any] | None = None, channel: dict[str, Any] | None = None, + callback_url: str | None = None, ) -> None: self.thread = thread self.message = message self.channel = channel + self.callback_url = callback_url # --------------------------------------------------------------------------- @@ -361,8 +372,25 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None: else None ) - # -- Message history (placeholder -- real impl would use MessageHistoryCache) - self._message_history = _MessageHistoryCache(self._state_adapter, config.message_history) + # -- Thread history (placeholder -- real impl would use ThreadHistoryCache) + # `config.message_history` is the deprecated alias; `thread_history` + # takes precedence when both are set (mirrors upstream + # `config.threadHistory ?? config.messageHistory`). + self._thread_history = _ThreadHistoryCache( + self._state_adapter, + config.thread_history if config.thread_history is not None else config.message_history, + ) + + # -- Transcripts API (cross-platform per-user persistence) ------------- + self._identity: IdentityResolver | None = config.identity + self._transcripts: TranscriptsApiImpl | None = None + if config.transcripts is not None: + if config.identity is None: + raise ValueError( + "ChatConfig.transcripts requires ChatConfig.identity to be set " + "— the cross-platform user key must be resolvable" + ) + self._transcripts = TranscriptsApiImpl(self._state_adapter, config.transcripts) # -- Logger ----------------------------------------------------------- if isinstance(config.logger, str): @@ -408,6 +436,24 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None: self._logger.debug("Chat instance created", {"adapters": list(config.adapters.keys())}) + # ======================================================================== + # Transcripts API + # ======================================================================== + + @property + def transcripts(self) -> TranscriptsApi: + """Cross-platform per-user transcript store. + + Available only when ``transcripts`` is configured on the Chat + instance (and an ``identity`` resolver is set). Raises on access + otherwise so callers fail loudly rather than silently no-op'ing. + """ + if self._transcripts is None: + raise ChatError( + "chat.transcripts is not configured — pass `transcripts` and `identity` to ChatConfig to enable it" + ) + return self._transcripts + # ======================================================================== # Singleton management # ======================================================================== @@ -909,10 +955,16 @@ def process_message( thread_id: str, message_or_factory: Message | Callable[[], Awaitable[Message]], options: WebhookOptions | None = None, - ) -> None: + ) -> asyncio.Task[None] | None: """Process an incoming message from an adapter. - Handles waitUntil registration and error catching. + Handles waitUntil registration and error catching. Returns the + handler task (``None`` only when no event loop is running) so + streaming callers can await full handler completion and observe + handler exceptions; fire-and-forget webhook callers may ignore it. + ``wait_until`` keeps the existing swallowed-error semantics — + platforms shouldn't retry on handler bugs. (Core slice of + vercel/chat#444; the @chat-adapter/web package itself is not ported.) """ async def _task() -> None: @@ -932,6 +984,7 @@ async def _task() -> None: ) if options and options.wait_until: options.wait_until(task) + return task def process_reaction( self, @@ -1008,6 +1061,7 @@ async def process_modal_submit( ) -> ModalResponse | None: """Process a modal form submission. Returns optional response.""" related = await self._retrieve_modal_context(event.adapter.name, context_id) + callback_url = related.get("callback_url") full_event = ModalSubmitEvent( adapter=event.adapter, @@ -1022,18 +1076,50 @@ async def process_modal_submit( raw=event.raw, ) + result: ModalResponse | None = None for pat in self._modal_submit_handlers: if not pat.callback_ids or event.callback_id in pat.callback_ids: try: response = await self._invoke_handler(pat.handler, full_event) if response is not None: - return response + result = response + break except Exception as exc: self._logger.error( "Modal submit handler error", {"callback_id": event.callback_id, "error": str(exc)}, ) - return None + + if callback_url and getattr(result, "action", None) != "errors": + # POST the form values to the modal's callback URL. The response + # is returned to the platform without waiting for the POST; the + # task is handed to options.wait_until for serverless callers. + payload = { + "type": "modal_submit", + "callbackId": event.callback_id, + "values": event.values, + "user": {"id": event.user.user_id, "name": event.user.user_name}, + } + + async def _post_modal_callback() -> None: + try: + post_result = await post_to_callback_url(callback_url, payload) + if post_result.error is not None: + self._logger.error( + "Modal callbackUrl POST failed", + {"callback_url": callback_url, "error": str(post_result.error)}, + ) + except Exception as error: # mirrors upstream's trailing .catch + self._logger.error( + "Modal callbackUrl POST failed", + {"callback_url": callback_url, "error": str(error)}, + ) + + task = _create_task(_post_modal_callback(), self._active_tasks) + if task is not None and options and options.wait_until: + options.wait_until(task) + + return result def process_modal_close( self, @@ -1215,7 +1301,12 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: self._logger.warn(f"Cannot open modal: {event.adapter.name} does not support modals") return None context_id = str(uuid.uuid4()) - self._store_modal_context(event.adapter.name, context_id, channel=channel) + await self._store_modal_context( + event.adapter.name, + context_id, + channel=channel, + callback_url=modal.get("callback_url") if isinstance(modal, dict) else None, + ) return await event.adapter.open_modal(trigger_id, modal, context_id) # type: ignore[union-attr] full_event = SlashCommandEvent( @@ -1242,29 +1333,31 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: # Modal context persistence # ======================================================================== - def _store_modal_context( + async def _store_modal_context( self, adapter_name: str, context_id: str, thread: ThreadImpl | None = None, message: Message | None = None, channel: Channel | None = None, + callback_url: str | None = None, ) -> None: + # Upstream ``storeModalContext`` is ``async`` and the ``openModal`` + # helper *awaits* it before calling ``adapter.openModal`` (chat.ts + # :1280/:1342/:1554). We mirror that: the state write must land + # before the modal can be submitted, otherwise with a remote + # (Redis/Postgres) backend a fast submit can race ahead of a + # fire-and-forget write and miss the stored callbackUrl/channel. key = f"modal-context:{adapter_name}:{context_id}" context = { "thread": thread.to_json() if thread else None, "message": message.to_json() if message else None, "channel": channel.to_json() if channel else None, + # camelCase: stored state is a serialization boundary shared + # with the TS SDK (matches the serialized thread/message keys). + "callbackUrl": callback_url, } - task = _create_task(self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS), self._active_tasks) - if task is not None: - task.add_done_callback( - lambda t: ( - self._logger.error("Failed to store modal context", {"context_id": context_id}) - if not t.cancelled() and t.exception() - else None - ) - ) + await self._state_adapter.set(key, context, MODAL_CONTEXT_TTL_MS) async def _retrieve_modal_context( self, @@ -1273,6 +1366,7 @@ async def _retrieve_modal_context( ) -> dict[str, Any]: if not context_id: return { + "callback_url": None, "related_thread": None, "related_message": None, "related_channel": None, @@ -1283,6 +1377,7 @@ async def _retrieve_modal_context( if not stored: return { + "callback_url": None, "related_thread": None, "related_message": None, "related_channel": None, @@ -1303,7 +1398,12 @@ async def _retrieve_modal_context( if stored.get("channel"): related_channel = ChannelImpl.from_json(stored["channel"], adapter) + # Accept both camelCase (written by this SDK and the TS SDK) and + # snake_case (hand-written state) — same tolerance as from_json. + callback_url = stored["callbackUrl"] if "callbackUrl" in stored else stored.get("callback_url") + return { + "callback_url": callback_url, "related_thread": related_thread, "related_message": related_message, "related_channel": related_channel, @@ -1328,6 +1428,46 @@ async def _handle_action_event(self, event: ActionEvent) -> None: self._logger.debug("Skipping action from self") return + # Decode a callback token (`__cb:`) planted at post time by + # process_card_callback_urls. When one resolves, handlers see the + # button's original value and the action payload is POSTed to the + # stored callback URL concurrently with the handlers. + callback_token = decode_callback_value(event.value).callback_token + + resolved = None + if callback_token: + resolved = await resolve_callback_url(callback_token, self._state_adapter) + + action_value = resolved.original_value if resolved is not None else event.value + + callback_url_task: asyncio.Task[Any] | None = None + if resolved is not None: + callback_url = resolved.url + # Wire payload: camelCase keys, optional keys omitted (not None) + # to mirror upstream's JSON.stringify semantics — hazard #7. + payload: dict[str, Any] = {"type": "action", "actionId": event.action_id} + if resolved.original_value is not None: + payload["value"] = resolved.original_value + payload["user"] = {"id": event.user.user_id, "name": event.user.user_name} + if event.thread_id is not None: + payload["threadId"] = event.thread_id + if event.message_id is not None: + payload["messageId"] = event.message_id + + async def _post_action_callback() -> None: + post_result = await post_to_callback_url(callback_url, payload) + if post_result.error is not None: + self._logger.error( + "Button callbackUrl POST failed", + { + "callback_url": callback_url, + "action_id": event.action_id, + "error": str(post_result.error), + }, + ) + + callback_url_task = _create_task(_post_action_callback(), self._active_tasks) + thread: ThreadImpl | None = None if event.thread_id: is_subscribed = False @@ -1382,12 +1522,13 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: context_id = str(uuid.uuid4()) channel_impl = thread.channel if thread else None - self._store_modal_context( + await self._store_modal_context( event.adapter.name, context_id, thread=thread, message=fetched_message, channel=channel_impl, + callback_url=modal.get("callback_url") if isinstance(modal, dict) else None, ) return await event.adapter.open_modal(trigger_id, modal, context_id) # type: ignore[union-attr] @@ -1398,7 +1539,7 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: message_id=event.message_id, user=event.user, action_id=event.action_id, - value=event.value, + value=action_value, trigger_id=event.trigger_id, raw=event.raw, _open_modal=_open_modal, @@ -1413,6 +1554,9 @@ async def _open_modal(modal: Any) -> dict[str, str] | None: self._logger.debug("Running matched action handler", {"action_id": event.action_id}) await self._invoke_handler(pat.handler, full_event) + if callback_url_task is not None: + await callback_url_task + # ======================================================================== # Reaction handling # ======================================================================== @@ -1818,12 +1962,16 @@ async def handle_incoming_message( self._logger.debug("Skipping duplicate message", {"message_id": message.id}) return - # Persist incoming message before acquiring lock - if adapter.persist_message_history: + # Persist incoming message before acquiring lock. + # `persist_message_history` is the deprecated adapter flag; either + # being truthy enables persistence (mirrors upstream + # `adapter.persistThreadHistory || adapter.persistMessageHistory`). + # Both flags are optional on the adapter, hence getattr. + if getattr(adapter, "persist_thread_history", None) or getattr(adapter, "persist_message_history", None): channel_id = adapter.channel_id_from_thread_id(thread_id) - appends = [self._message_history.append(thread_id, message)] + appends = [self._thread_history.append(thread_id, message)] if channel_id != thread_id: - appends.append(self._message_history.append(channel_id, message)) + appends.append(self._thread_history.append(channel_id, message)) await asyncio.gather(*appends) # Resolve lock key @@ -2158,6 +2306,27 @@ async def _dispatch_to_handlers( thread = self._create_thread(adapter, thread_id, message, is_subscribed) + # Resolve cross-platform user key (Transcripts API). Cached on the + # Message instance so handlers and the Transcripts API see the same + # value without re-invoking the resolver. + if self._identity is not None and message.user_key is None: + try: + resolved = self._identity(IdentityContext(adapter=adapter.name, author=message.author, message=message)) + if inspect.isawaitable(resolved): + resolved = await resolved + if resolved: + message.user_key = resolved + except Exception as err: + self._logger.warn( + "Identity resolver threw; skipping userKey", + { + "error": err, + "adapter": adapter.name, + "thread_id": thread_id, + "author_user_id": message.author.user_id, + }, + ) + # DM routing is_dm = ( adapter.is_dm(thread_id) # type: ignore[union-attr] @@ -2240,7 +2409,14 @@ def _create_thread( logger=self._logger, streaming_update_interval_ms=self._streaming_update_interval_ms, fallback_streaming_placeholder_text=self._fallback_streaming_placeholder_text, - message_history=(self._message_history if adapter.persist_message_history else None), + thread_history=( + self._thread_history + if ( + getattr(adapter, "persist_thread_history", None) + or getattr(adapter, "persist_message_history", None) + ) + else None + ), ) ) @@ -2483,12 +2659,19 @@ def _message_from_json(data: dict[str, Any]) -> Message: # --------------------------------------------------------------------------- -# Minimal MessageHistoryCache (placeholder -- real impl uses StateAdapter lists) +# Minimal ThreadHistoryCache (placeholder -- real impl uses StateAdapter lists) # --------------------------------------------------------------------------- +# Key prefix for thread history entries. +# +# Kept as ``msg-history:`` for backwards compatibility — renaming would +# silently orphan every existing user's stored data. The user-facing names +# changed; the storage shape didn't. (Matches KEY_PREFIX in thread_history.py.) +_THREAD_HISTORY_KEY_PREFIX = "msg-history:" + -class _MessageHistoryCache: - """Lightweight in-SDK message history cache backed by the state adapter.""" +class _ThreadHistoryCache: + """Lightweight in-SDK per-thread history cache backed by the state adapter.""" def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) -> None: self._state = state @@ -2496,9 +2679,9 @@ def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) -> self._ttl_ms = (config or {}).get("ttl_ms", 30 * 24 * 60 * 60 * 1000) async def append(self, thread_id: str, message: Message) -> None: - key = f"msg-history:{thread_id}" + key = f"{_THREAD_HISTORY_KEY_PREFIX}{thread_id}" # Serialize with raw nulled out to save storage (matches - # MessageHistoryCache.append in message_history.py). Without this, + # ThreadHistoryCache.append in thread_history.py). Without this, # SentMessage.raw — now populated post-#117 with platform payloads # like Slack team_id/user_id, Discord guild IDs — would persist to # the state adapter on every reply, inflating storage and PII surface. @@ -2507,7 +2690,7 @@ async def append(self, thread_id: str, message: Message) -> None: await self._state.append_to_list(key, data, max_length=self._max_messages, ttl_ms=self._ttl_ms) async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: - key = f"msg-history:{thread_id}" + key = f"{_THREAD_HISTORY_KEY_PREFIX}{thread_id}" raw_list = await self._state.get_list(key) messages = [_message_from_json(r) if isinstance(r, dict) else r for r in raw_list] if limit is not None: diff --git a/src/chat_sdk/message_history.py b/src/chat_sdk/message_history.py index b2121e7a..70d6cc1e 100644 --- a/src/chat_sdk/message_history.py +++ b/src/chat_sdk/message_history.py @@ -1,86 +1,35 @@ -"""Persistent message history cache backed by the StateAdapter. +"""Deprecated — renamed to ``chat_sdk.thread_history``. -Python port of message-history.ts. +Python port of message-history.ts (which upstream turned into a +backwards-compatibility re-export shim when the cache was renamed to +``ThreadHistoryCache`` in thread-history.ts). -Used by adapters that lack server-side message history APIs -(e.g. WhatsApp, Telegram). Messages are atomically appended via -``state.append_to_list()``, which is safe without holding a thread lock. +This module is preserved for backwards compatibility and re-exports the +new names under the old identifiers. New code should import +``ThreadHistoryCache`` and ``ThreadHistoryConfig`` from +``chat_sdk.thread_history`` directly. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Any - -from chat_sdk.types import Message, StateAdapter - -# Default maximum number of messages to store per thread -DEFAULT_MAX_MESSAGES = 100 - -# Default TTL for message history (7 days in milliseconds) -DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 - -# Key prefix for message history entries -KEY_PREFIX = "msg-history:" - - -@dataclass -class MessageHistoryConfig: - """Configuration for message history cache.""" - - max_messages: int = DEFAULT_MAX_MESSAGES - ttl_ms: int = DEFAULT_TTL_MS - - -class MessageHistoryCache: - """Persistent message history cache backed by the StateAdapter. - - Used by adapters that lack server-side message history APIs - (e.g. WhatsApp, Telegram). Messages are atomically appended via - ``state.append_to_list()``, which is safe without holding a thread lock. - """ - - def __init__( - self, - state: StateAdapter, - config: MessageHistoryConfig | None = None, - ) -> None: - self._state = state - cfg = config or MessageHistoryConfig() - self._max_messages = cfg.max_messages - self._ttl_ms = cfg.ttl_ms - - async def append(self, thread_id: str, message: Message) -> None: - """Atomically append a message to the history for a thread. - - Trims to ``max_messages`` (keeps newest) and refreshes TTL. - """ - key = f"{KEY_PREFIX}{thread_id}" - - # Serialize with raw nulled out to save storage - serialized = message.to_json() - serialized["raw"] = None - - await self._state.append_to_list( - key, - serialized, - max_length=self._max_messages, - ttl_ms=self._ttl_ms, - ) - - async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: - """Get messages for a thread in chronological order (oldest first). - - Parameters - ---------- - thread_id: - The thread ID. - limit: - Optional limit on number of messages to return (returns newest N). - """ - key = f"{KEY_PREFIX}{thread_id}" - stored: list[dict[str, Any]] = await self._state.get_list(key) - - sliced = stored[len(stored) - limit :] if limit and len(stored) > limit else stored - - return [Message.from_json(s) for s in sliced] +from chat_sdk.thread_history import ( + DEFAULT_MAX_MESSAGES, + DEFAULT_TTL_MS, + KEY_PREFIX, + ThreadHistoryCache, + ThreadHistoryConfig, +) + +# Deprecated: use ``ThreadHistoryCache`` from ``chat_sdk.thread_history``. +MessageHistoryCache = ThreadHistoryCache + +# Deprecated: use ``ThreadHistoryConfig`` from ``chat_sdk.thread_history``. +MessageHistoryConfig = ThreadHistoryConfig + +__all__ = [ + "DEFAULT_MAX_MESSAGES", + "DEFAULT_TTL_MS", + "KEY_PREFIX", + "MessageHistoryCache", + "MessageHistoryConfig", +] diff --git a/src/chat_sdk/modals.py b/src/chat_sdk/modals.py index 50ab6a65..4d64b69a 100644 --- a/src/chat_sdk/modals.py +++ b/src/chat_sdk/modals.py @@ -89,6 +89,8 @@ class ModalElement(TypedDict, total=False): type: str # "modal" title: str callback_id: str + # URL to POST form values to when this modal is submitted + callback_url: str submit_label: str close_label: str notify_on_close: bool @@ -127,6 +129,7 @@ def Modal( *, title: str, callback_id: str, + callback_url: str | None = None, children: list[ModalChild] | None = None, submit_label: str | None = None, close_label: str | None = None, @@ -140,6 +143,8 @@ def Modal( "callback_id": callback_id, "children": children or [], } + if callback_url is not None: + result["callback_url"] = callback_url if submit_label is not None: result["submit_label"] = submit_label if close_label is not None: diff --git a/src/chat_sdk/shared/mock_adapter.py b/src/chat_sdk/shared/mock_adapter.py index f7fcef43..cdaa20d9 100644 --- a/src/chat_sdk/shared/mock_adapter.py +++ b/src/chat_sdk/shared/mock_adapter.py @@ -82,7 +82,11 @@ def __init__(self, name: str = "slack") -> None: self.user_name = f"{name}-bot" self.bot_user_id: str | None = None self.lock_scope = None + # Deprecated alias read alongside persist_thread_history; tests set + # either flag per-instance (mirrors the upstream mock, where neither + # is defined until a test assigns it). self.persist_message_history = None + self.persist_thread_history = None # Call recorders self._post_calls: list[tuple[str, AdapterPostableMessage]] = [] diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 06f76790..1eea4afb 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -10,10 +10,11 @@ import asyncio import contextvars from collections.abc import AsyncIterator -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable +from chat_sdk.callback_url import process_card_callback_urls from chat_sdk.errors import ChatNotImplementedError from chat_sdk.logger import Logger from chat_sdk.plan import is_postable_object, post_postable_object @@ -269,7 +270,7 @@ class _ThreadImplConfig: # Direct adapter mode adapter: Adapter | None = None state_adapter: StateAdapter | None = None - message_history: Any = None # MessageHistoryCache + thread_history: Any = None # ThreadHistoryCache # Lazy resolution mode adapter_name: str | None = None @@ -303,7 +304,7 @@ def __init__(self, config: _ThreadImplConfig) -> None: self._adapter: Adapter | None = config.adapter self._adapter_name: str | None = config.adapter_name self._state_adapter_instance: StateAdapter | None = config.state_adapter - self._message_history: Any = config.message_history + self._thread_history: Any = config.thread_history # Lazy channel cache self._channel_cache: ChannelImpl | None = None @@ -405,7 +406,7 @@ def channel(self) -> Channel: state_adapter=self._state_adapter, is_dm=self._is_dm, channel_visibility=self._channel_visibility, - message_history=self._message_history, + thread_history=self._thread_history, ) ) return self._channel_cache @@ -419,7 +420,7 @@ async def messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter thread_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -438,8 +439,8 @@ async def messages(self) -> AsyncIterator[Message]: cursor = result.next_cursor # Fallback to cached history - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(thread_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(thread_id) for msg in reversed(cached): yield msg @@ -450,7 +451,7 @@ async def all_messages(self) -> AsyncIterator[Message]: """ adapter = self.adapter thread_id = self._id - message_history = self._message_history + thread_history = self._thread_history cursor: str | None = None yielded_any = False @@ -467,8 +468,8 @@ async def all_messages(self) -> AsyncIterator[Message]: break cursor = result.next_cursor - if not yielded_any and message_history is not None: - cached: list[Message] = await message_history.get_messages(thread_id) + if not yielded_any and thread_history is not None: + cached: list[Message] = await thread_history.get_messages(thread_id) for msg in cached: yield msg @@ -562,10 +563,10 @@ async def post( raw = await self._handle_postable_object(message) # Cache in history with the real message ID (upstream skips this, # but that's a gap — posted messages should appear in history). - if self._message_history is not None and raw is not None: + if self._thread_history is not None and raw is not None: fallback = message.get_fallback_text() if hasattr(message, "get_fallback_text") else "" sent = self._create_sent_message(raw.id, PostableMarkdown(markdown=fallback), raw.thread_id) - await self._message_history.append(self._id, _to_message(sent)) + await self._thread_history.append(self._id, _to_message(sent)) return message # Handle AsyncIterable (streaming) @@ -573,11 +574,12 @@ async def post( return await self._handle_stream(message) postable: AdapterPostableMessage = message # type: ignore[assignment] + postable = await self._process_callback_urls(postable) raw_msg = await self.adapter.post_message(self._id, postable) result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id, raw=raw_msg.raw) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(result)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(result)) return result @@ -604,6 +606,8 @@ async def post_ephemeral( """ user_id = user if isinstance(user, str) else user.user_id + message = await self._process_callback_urls(message) # type: ignore[assignment] + # Try native ephemeral if hasattr(self.adapter, "post_ephemeral") and self.adapter.post_ephemeral: # type: ignore[union-attr] return await self.adapter.post_ephemeral(self._id, user_id, message) # type: ignore[union-attr] @@ -624,6 +628,28 @@ async def post_ephemeral( return None + async def _process_callback_urls( + self, + postable: str | AdapterPostableMessage, + ) -> str | AdapterPostableMessage: + """Encode ``callback_url`` buttons in outgoing cards (vercel/chat#454).""" + if isinstance(postable, str): + return postable + + if isinstance(postable, dict) and postable.get("type") == "card": + return await process_card_callback_urls(postable, self._state_adapter) + + if ( + isinstance(postable, PostableCard) + and isinstance(postable.card, dict) + and postable.card.get("type") == "card" + ): + processed = await process_card_callback_urls(postable.card, self._state_adapter) + if processed is not postable.card: + return replace(postable, card=processed) + + return postable + async def schedule( self, message: AdapterPostableMessage, @@ -631,6 +657,8 @@ async def schedule( post_at: datetime, ) -> ScheduledMessage: """Schedule a message for future delivery.""" + message = await self._process_callback_urls(message) # type: ignore[assignment] + if not hasattr(self.adapter, "schedule_message") or not self.adapter.schedule_message: # type: ignore[union-attr] raise ChatNotImplementedError( self.adapter.name, @@ -716,8 +744,8 @@ async def _wrapped_stream() -> AsyncIterator[str | StreamChunk]: PostableMarkdown(markdown=recorded_text), raw_result.thread_id, ) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) return sent # Fallback: post + edit with throttling (text-only) @@ -930,8 +958,8 @@ async def _edit_loop() -> None: PostableMarkdown(markdown=last_edit_content), thread_id_for_edits, ) - if self._message_history is not None: - await self._message_history.append(self._id, _to_message(sent)) + if self._thread_history is not None: + await self._thread_history.append(self._id, _to_message(sent)) # If the stream raised mid-way, we've now flushed whatever partial # content was rendered (so the user sees real content instead of a @@ -954,8 +982,8 @@ async def refresh(self) -> None: result = await self.adapter.fetch_messages(self._id, FetchOptions(limit=50)) if result.messages: self._recent_messages = result.messages - elif self._message_history is not None: - self._recent_messages = await self._message_history.get_messages(self._id, 50) + elif self._thread_history is not None: + self._recent_messages = await self._thread_history.get_messages(self._id, 50) else: self._recent_messages = [] @@ -1036,7 +1064,7 @@ def from_json( # Every attribute that embeds a reference to the old context # gets cleared here: `_state_adapter_instance` (old chat's # state backend), `_channel_cache` (old adapter's channel), - # `_message_history` (old chat's history cache), + # `_thread_history` (old chat's history cache), # `_recent_messages` (old adapter's fetched content), and # `_is_subscribed_context` (handler-context flag from the # old chat — a thread rebound to a new context shouldn't @@ -1045,7 +1073,7 @@ def from_json( if adapter is not None or chat is not None: thread._state_adapter_instance = None thread._channel_cache = None - thread._message_history = None + thread._thread_history = None thread._recent_messages = [] thread._is_subscribed_context = False else: @@ -1140,6 +1168,7 @@ def _create_sent_message( plain_text, formatted, attachments = _extract_message_content(postable) async def _edit(new_content: Any) -> SentMessage: + new_content = await thread_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return thread_impl._create_sent_message(message_id, new_content) @@ -1185,6 +1214,7 @@ def create_sent_message_from_message(self, message: Message) -> SentMessage: thread_impl = self async def _edit(new_content: Any) -> SentMessage: + new_content = await thread_impl._process_callback_urls(new_content) await adapter.edit_message(thread_id, message_id, new_content) return thread_impl._create_sent_message(message_id, new_content, thread_id) @@ -1323,4 +1353,4 @@ class _ChannelImplConfigForThread: state_adapter: StateAdapter is_dm: bool = False channel_visibility: ChannelVisibility = "unknown" - message_history: Any = None + thread_history: Any = None diff --git a/src/chat_sdk/thread_history.py b/src/chat_sdk/thread_history.py new file mode 100644 index 00000000..82129cdc --- /dev/null +++ b/src/chat_sdk/thread_history.py @@ -0,0 +1,97 @@ +"""Persistent per-thread history cache backed by the StateAdapter. + +Python port of thread-history.ts (renamed upstream from message-history.ts). + +Used by adapters that lack server-side message history APIs +(e.g. WhatsApp, Telegram). Messages are atomically appended via +``state.append_to_list()``, which is safe without holding a thread lock. + +Distinct from the cross-platform per-user Transcripts API (see +``transcripts.py``) — this cache is keyed by thread, not user. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from chat_sdk.types import Message, StateAdapter + +# Default maximum number of messages to store per thread +DEFAULT_MAX_MESSAGES = 100 + +# Default TTL for thread history (7 days in milliseconds) +DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +# Key prefix for thread history entries. +# +# Kept as ``msg-history:`` for backwards compatibility — renaming would +# silently orphan every existing user's stored data. The user-facing names +# changed; the storage shape didn't. +KEY_PREFIX = "msg-history:" + + +@dataclass +class ThreadHistoryConfig: + """Configuration for the thread history cache.""" + + max_messages: int = DEFAULT_MAX_MESSAGES + ttl_ms: int = DEFAULT_TTL_MS + + +class ThreadHistoryCache: + """Persistent per-thread history cache backed by the StateAdapter. + + Used by adapters that lack server-side message history APIs + (e.g. WhatsApp, Telegram). Messages are atomically appended via + ``state.append_to_list()``, which is safe without holding a thread lock. + + Distinct from the cross-platform per-user + :class:`~chat_sdk.transcripts.TranscriptsApiImpl` (see ``transcripts.py``) + — this cache is keyed by thread, not user. + """ + + def __init__( + self, + state: StateAdapter, + config: ThreadHistoryConfig | None = None, + ) -> None: + self._state = state + cfg = config or ThreadHistoryConfig() + self._max_messages = cfg.max_messages + self._ttl_ms = cfg.ttl_ms + + async def append(self, thread_id: str, message: Message) -> None: + """Atomically append a message to the history for a thread. + + Trims to ``max_messages`` (keeps newest) and refreshes TTL. + """ + key = f"{KEY_PREFIX}{thread_id}" + + # Serialize with raw nulled out to save storage + serialized = message.to_json() + serialized["raw"] = None + + await self._state.append_to_list( + key, + serialized, + max_length=self._max_messages, + ttl_ms=self._ttl_ms, + ) + + async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]: + """Get messages for a thread in chronological order (oldest first). + + Parameters + ---------- + thread_id: + The thread ID. + limit: + Optional limit on number of messages to return (returns newest N). + """ + key = f"{KEY_PREFIX}{thread_id}" + stored: list[dict[str, Any]] = await self._state.get_list(key) + + sliced = stored[len(stored) - limit :] if limit and len(stored) > limit else stored + + return [Message.from_json(s) for s in sliced] diff --git a/src/chat_sdk/transcripts.py b/src/chat_sdk/transcripts.py new file mode 100644 index 00000000..707be383 --- /dev/null +++ b/src/chat_sdk/transcripts.py @@ -0,0 +1,197 @@ +"""Cross-platform per-user transcript store. + +Python port of transcripts.ts. + +Backed by ``StateAdapter.append_to_list`` — every built-in state adapter +supports it with no contract changes. Keyed by a resolved cross-platform +user key from ``ChatConfig.identity``. + +Distinct from :class:`~chat_sdk.thread_history.ThreadHistoryCache` (which is +per-thread, used by adapters that lack server-side history APIs). +""" + +from __future__ import annotations + +import re +import time +import uuid +from typing import Any + +from chat_sdk.types import ( + AppendInput, + AppendOptions, + CountQuery, + DeleteResult, + DeleteTarget, + DurationString, + ListQuery, + Message, + Postable, + StateAdapter, + TranscriptEntry, + TranscriptRole, + TranscriptsConfig, +) + +KEY_PREFIX = "transcripts:user:" +DEFAULT_MAX_PER_USER = 200 +DEFAULT_LIST_LIMIT = 50 +DURATION_RE = re.compile(r"^(\d+)([smhd])$") + +# Sentinel value written by ``delete()`` so the underlying list is +# functionally empty without needing a ``clear_list`` primitive on the state +# adapter contract. +# +# ``append_to_list(key, tombstone, max_length=1)`` evicts every prior entry +# across all built-in state adapters (memory trims the shared list; +# redis uses LTRIM; postgres trims oldest rows). The remaining single +# tombstone is filtered out by ``list()`` and ``count()``, and is pushed out +# naturally on the next append once ``max_per_user`` writes have happened. +# +# The marker string matches the upstream TS SDK so stores are interoperable. +TOMBSTONE_MARKER = "__chatSdkTombstone" + +MS_PER_UNIT = { + "s": 1_000, + "m": 60_000, + "h": 3_600_000, + "d": 86_400_000, +} + + +def _is_tombstone(value: Any) -> bool: + return isinstance(value, dict) and value.get(TOMBSTONE_MARKER) is True + + +def _key_for(user_key: str) -> str: + return f"{KEY_PREFIX}{user_key}" + + +def _parse_duration(value: int | float | DurationString | None) -> int | float | None: + if value is None: + return None + # Numbers pass through unchanged (upstream `typeof value === "number"`). + # bool is an int subclass but is not a valid duration — fall through to + # the string path's error (mirrors upstream, where a boolean fails the + # regex and raises). + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + match = DURATION_RE.match(value) if isinstance(value, str) else None + if not match: + raise ValueError(f'Invalid duration: {value} (expected number of ms, or "[smhd]")') + n = int(match.group(1)) + return n * MS_PER_UNIT[match.group(2)] + + +class TranscriptsApiImpl: + """Cross-platform per-user transcript store, backed by + ``StateAdapter.append_to_list``. + + Distinct from :class:`~chat_sdk.thread_history.ThreadHistoryCache` (which + is per-thread, used by adapters that lack server-side history APIs). This + store is keyed by a resolved cross-platform user key from + ``ChatConfig.identity``. + """ + + def __init__(self, state: StateAdapter, config: TranscriptsConfig) -> None: + self._state = state + self._max_per_user = config.max_per_user if config.max_per_user is not None else DEFAULT_MAX_PER_USER + parsed_retention = _parse_duration(config.retention) + # append_to_list takes ttl_ms: int | None; fractional ms are meaningless + self._retention_ms = int(parsed_retention) if parsed_retention is not None else None + self._store_formatted = config.store_formatted + + async def append( + self, + thread: Postable, + message: Message | AppendInput, + options: AppendOptions | None = None, + ) -> TranscriptEntry | None: + """Persist a Message (or AppendInput) under the user key. + + - For Message: ``user_key`` is read from the Message instance (set by + the SDK during inbound dispatch via the configured IdentityResolver). + No-op (returns ``None``) if the Message has no ``user_key``. + - For AppendInput: ``options.user_key`` is required. + """ + if isinstance(message, Message): + user_key = message.user_key + role: TranscriptRole = "user" + platform_message_id: str | None = message.id + if not user_key: + return None + else: + user_key = options.user_key if options is not None else None + role = message.role + platform_message_id = message.platform_message_id + if not user_key: + raise ValueError("transcripts.append: options.user_key is required when appending an AppendInput") + + entry = TranscriptEntry( + id=str(uuid.uuid4()), + user_key=user_key, + role=role, + text=message.text, + platform=thread.adapter.name, + thread_id=thread.id, + timestamp=int(time.time() * 1000), + ) + if self._store_formatted and message.formatted: + entry.formatted = message.formatted + if platform_message_id is not None: + entry.platform_message_id = platform_message_id + + await self._state.append_to_list( + _key_for(user_key), + entry.to_json(), + max_length=self._max_per_user, + ttl_ms=self._retention_ms, + ) + + return entry + + async def list(self, query: ListQuery) -> list[TranscriptEntry]: + """Return the most recent entries in chronological order (oldest + first), capped at ``query.limit`` (default 50). + + Pagination is intentionally not supported — the store keeps at most + ``max_per_user`` entries per user. To widen the window, raise + ``max_per_user``; to fetch a different slice, narrow with + ``thread_id`` / ``platforms`` / ``roles``. + """ + raw = await self._state.get_list(_key_for(query.user_key)) + filtered = [TranscriptEntry.from_json(entry) for entry in raw if not _is_tombstone(entry)] + + if query.platforms: + platforms = set(query.platforms) + filtered = [m for m in filtered if m.platform in platforms] + if query.thread_id is not None: + tid = query.thread_id + filtered = [m for m in filtered if m.thread_id == tid] + if query.roles: + roles = set(query.roles) + filtered = [m for m in filtered if m.role in roles] + + limit = query.limit if query.limit is not None else DEFAULT_LIST_LIMIT + if len(filtered) > limit: + filtered = filtered[len(filtered) - limit :] + return filtered + + async def count(self, query: CountQuery) -> int: + """Total stored count for a user key.""" + raw = await self._state.get_list(_key_for(query.user_key)) + return sum(1 for entry in raw if not _is_tombstone(entry)) + + async def delete(self, target: DeleteTarget) -> DeleteResult: + """GDPR / DSR delete — wipes every stored message under the user key.""" + key = _key_for(target.user_key) + existing = await self._state.get_list(key) + previous = sum(1 for entry in existing if not _is_tombstone(entry)) + # Append a tombstone with max_length=1 to evict every prior entry. The + # remaining tombstone is filtered out by list()/count(), and is + # naturally pushed out by the next append once max_per_user writes + # accumulate. (Not ``state.delete(key)`` — that only addresses the + # k/v namespace on every non-memory state adapter.) + tombstone: dict[str, Any] = {TOMBSTONE_MARKER: True} + await self._state.append_to_list(key, tombstone, max_length=1, ttl_ms=self._retention_ms) + return DeleteResult(deleted=previous) diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 28cb259a..a7588387 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -5,6 +5,7 @@ from __future__ import annotations +import asyncio from collections.abc import AsyncIterable, Awaitable, Callable from dataclasses import dataclass, field from datetime import datetime @@ -411,6 +412,15 @@ class Message: is_mention: bool | None = None links: list[LinkPreview] | None = None raw: Any = None + # Cross-platform user key for this message's author. + # + # Set by the Chat SDK before passing the message to handlers, when + # ``ChatConfig.identity`` is configured. ``None`` if no resolver is + # configured or when the resolver returned ``None``. + # + # Used by the Transcripts API to look up / append per-user transcripts. + # Not part of the serialized ``SerializedMessage`` shape. + user_key: str | None = None def to_json(self) -> dict[str, Any]: """Serialize to JSON-compatible dict. @@ -1226,8 +1236,13 @@ def user_name(self) -> str: ... def bot_user_id(self) -> str | None: ... @property def lock_scope(self) -> LockScope | None: ... - @property - def persist_message_history(self) -> bool | None: ... + + # NOTE: the optional history-persistence flags (``persist_thread_history`` + # and its deprecated alias ``persist_message_history``) are declared on + # ``BaseAdapter``, NOT here. Adding optional hooks to this structural + # Protocol makes them *required* members and breaks adapters that don't + # define them (several adapters satisfy the Protocol without extending + # BaseAdapter). The SDK reads both flags via ``getattr(..., None)``. def encode_thread_id(self, platform_data: Any) -> str: ... def decode_thread_id(self, thread_id: str) -> Any: ... @@ -1285,10 +1300,16 @@ def bot_user_id(self) -> str | None: def lock_scope(self) -> LockScope | None: return None + # Deprecated: renamed to ``persist_thread_history``. Kept for + # backwards compatibility; either flag being truthy enables persistence. @property def persist_message_history(self) -> bool | None: return None + @property + def persist_thread_history(self) -> bool | None: + return None + # -- Optional methods with default (not-implemented) -------------------- async def stream( @@ -1455,17 +1476,38 @@ class ChatConfig: # Milliseconds to remember a message ID for deduplication (default 5 min). dedupe_ttl_ms: int = 300000 fallback_streaming_placeholder_text: str | None = "..." + # Resolves a stable cross-platform user key from inbound messages. + # + # Required when ``transcripts`` is configured. Called once per inbound + # message during dispatch; the result is attached to the Message + # instance as ``message.user_key`` for handlers to use. + identity: IdentityResolver | None = None # Whether locks are scoped per-thread or per-channel. # Can also be a callable that inspects context and returns the scope. lock_scope: LockScope | Callable[..., LockScope | Awaitable[LockScope]] | None = None logger: Logger | LogLevel | None = None - # Configuration dict forwarded to MessageHistoryCache - # (e.g. {"max_messages": 50, "persist": True}). + # Deprecated: renamed to ``thread_history``. Both fields are read for + # backwards compatibility; ``thread_history`` takes precedence when both + # are set. message_history: dict[str, Any] | None = None # What to do when a lock is already held: "drop" the new message, # "force" acquire, or a callable that decides at runtime. on_lock_conflict: OnLockConflict | None = None streaming_update_interval_ms: int = 500 + # Configuration dict for persistent per-thread message history backfill + # (e.g. {"max_messages": 50, "ttl_ms": 86_400_000}). + # + # Only used by adapters that set ``persist_thread_history`` (e.g. + # Telegram, WhatsApp). Distinct from ``transcripts`` (the cross-platform + # per-user Transcripts API). + thread_history: dict[str, Any] | None = None + # Cross-platform per-user message persistence. + # + # When set, ``chat.transcripts`` is available for append/list/count/delete + # keyed by a resolved cross-platform user key. + # + # Requires ``identity`` to also be set; the constructor raises otherwise. + transcripts: TranscriptsConfig | None = None # ============================================================================= @@ -1486,7 +1528,7 @@ def process_message( thread_id: str, message: Message | Callable[[], Awaitable[Message]], options: WebhookOptions | None = None, - ) -> None: ... + ) -> asyncio.Task[None] | None: ... async def handle_incoming_message(self, adapter: Adapter, thread_id: str, message: Message) -> None: ... def process_action(self, event: Any, options: WebhookOptions | None = None) -> None: ... def process_reaction(self, event: Any, options: WebhookOptions | None = None) -> None: ... @@ -1511,6 +1553,12 @@ def process_member_joined_channel( self, event: MemberJoinedChannelEvent, options: WebhookOptions | None = None ) -> None: ... + # Cross-platform per-user transcript store. Raises on access when + # ``transcripts`` is not configured on the Chat instance — callers should + # check ``ChatConfig.transcripts`` if they need a no-raise guard. + @property + def transcripts(self) -> TranscriptsApi: ... + # ============================================================================= # Postable / Channel / Thread interfaces (simplified for adapter use) @@ -1679,3 +1727,221 @@ def create_sent_message_from_message(self, message: Message) -> SentMessage: def to_json(self) -> dict[str, Any]: """Serialize the thread to a plain JSON object.""" ... + + +# ============================================================================= +# Transcripts API (cross-platform per-user message persistence) +# ============================================================================= + + +@dataclass +class IdentityContext: + """Context passed to an :data:`IdentityResolver`.""" + + # Adapter name (e.g. "slack", "discord"). + adapter: str + author: Author + message: Message + + +# Resolves a stable, cross-platform user key from an inbound message context. +# +# Return ``None`` to skip persistence for this event (unknown user, system +# message, or the bot itself). The SDK fails loudly rather than silently +# falling back to a platform-specific ID. May be sync or async. +IdentityResolver = Callable[[IdentityContext], "str | None | Awaitable[str | None]"] + +# Role tag on a stored message. +# +# - ``user``: produced by the resolved end-user +# - ``assistant``: produced by this bot +# - ``system``: SDK-injected marker (handoff, summary). Adapters never produce it. +TranscriptRole = Literal["user", "assistant", "system"] + +# Duration shorthand: e.g. ``"7d"``, ``"30m"``, ``"2h"``, ``"45s"``. +# (TS models this as a template-literal type; Python uses a plain ``str``.) +DurationString = str + + +@dataclass +class TranscriptEntry: + """A stored transcript entry. + + Serialized at the storage boundary via :meth:`to_json` using the same + camelCase shape the upstream TS SDK writes, so stores are interoperable. + """ + + # UUID assigned by the SDK at append time. Opaque — not lexicographically + # sortable. Entries are returned by ``list()`` in append order (the + # underlying list semantics of ``state.append_to_list``); use + # ``timestamp`` to reason about ordering across stores. + id: str + # Cross-platform user key from the IdentityResolver. + user_key: str + role: TranscriptRole + # Plain-text body — canonical field for prompt building. + text: str + # Originating adapter name. + platform: str + # Originating thread ID. + thread_id: str + # ms-since-epoch, set at append time on the SDK side. + timestamp: int + # mdast AST. Only present when ``transcripts.store_formatted`` is true. + formatted: FormattedContent | None = None + # Platform-native message ID, when known. + platform_message_id: str | None = None + + def to_json(self) -> dict[str, Any]: + """Serialize to the camelCase storage shape (optional keys omitted).""" + result: dict[str, Any] = { + "id": self.id, + "userKey": self.user_key, + "role": self.role, + "text": self.text, + "platform": self.platform, + "threadId": self.thread_id, + "timestamp": self.timestamp, + } + if self.formatted is not None: + result["formatted"] = self.formatted + if self.platform_message_id is not None: + result["platformMessageId"] = self.platform_message_id + return result + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TranscriptEntry: + """Reconstruct an entry from its camelCase storage shape.""" + return cls( + id=data.get("id", ""), + user_key=data.get("userKey", ""), + role=data.get("role", "user"), + text=data.get("text", ""), + platform=data.get("platform", ""), + thread_id=data.get("threadId", ""), + timestamp=data.get("timestamp", 0), + formatted=data.get("formatted"), + platform_message_id=data.get("platformMessageId"), + ) + + +@dataclass +class TranscriptsConfig: + """Configuration for the cross-platform per-user Transcripts API.""" + + # Hard cap; older messages evicted on append. Default 200. + max_per_user: int | None = None + # Default retention applied as the list TTL (ms, or a DurationString such + # as "7d"). Refreshed on every append (matches ``append_to_list`` + # semantics). Omit for no expiry. + retention: int | DurationString | None = None + # Persist ``formatted`` (mdast). Default False to keep storage small. + store_formatted: bool = False + + +@dataclass +class AppendInput: + """Input shape for appending a non-Message (e.g. an assistant reply you + just posted via ``thread.post()``).""" + + role: TranscriptRole + text: str + formatted: FormattedContent | None = None + platform_message_id: str | None = None + + +@dataclass +class AppendOptions: + """Options for :meth:`TranscriptsApi.append`.""" + + # Required when appending an ``AppendInput`` (assistant/system role) — the + # SDK has no Message instance from which to read the resolved key. + # + # Ignored when appending a Message; the Message's own ``user_key`` is used. + user_key: str | None = None + + +@dataclass +class ListQuery: + """Query for :meth:`TranscriptsApi.list`.""" + + user_key: str + # Newest N kept (still returned in chronological order). Default 50. + limit: int | None = None + # Filter to a subset of adapter names. + platforms: list[str] | None = None + # Filter to specific roles. Default: all. + roles: list[TranscriptRole] | None = None + # Filter to a single thread. + thread_id: str | None = None + + +@dataclass +class DeleteTarget: + """Target for :meth:`TranscriptsApi.delete`. Wipes every stored message + under the given user key.""" + + user_key: str + + +@dataclass +class CountQuery: + """Query shape for :meth:`TranscriptsApi.count`.""" + + user_key: str + + +@dataclass +class DeleteResult: + """Result of :meth:`TranscriptsApi.delete`. + + Python-side named type for the upstream inline ``{ deleted: number }`` + return shape. + """ + + deleted: int + + +class TranscriptsApi(Protocol): + """Cross-platform per-user message store. + + Distinct from the existing per-thread ``thread_history`` config (which + exists to backfill thread context for adapters that lack server-side + history APIs). The Transcripts API is keyed by a resolved cross-platform + user key and is intended for transcript-style use cases (LLM context + building, audit). + """ + + async def append( + self, + thread: Postable, + message: Message | AppendInput, + options: AppendOptions | None = None, + ) -> TranscriptEntry | None: + """Persist a Message (or AppendInput) under the user key. + + - For Message: ``user_key`` is read from the Message instance (set by + the SDK during inbound dispatch via the configured IdentityResolver). + No-op if the Message has no ``user_key`` (resolver returned None). + - For AppendInput: ``options.user_key`` is required. + """ + ... + + async def count(self, query: CountQuery) -> int: + """Total stored count for a user key.""" + ... + + async def delete(self, target: DeleteTarget) -> DeleteResult: + """GDPR / DSR delete — wipes every stored message under the user key.""" + ... + + async def list(self, query: ListQuery) -> list[TranscriptEntry]: + """Return the most recent entries in chronological order (oldest + first), capped at ``query.limit`` (default 50). + + Pagination is intentionally not supported — the store keeps at most + ``transcripts.max_per_user`` entries per user. To widen the window, + raise ``max_per_user``; to fetch a different slice, narrow with + ``thread_id`` / ``platforms`` / ``roles``. + """ + ... diff --git a/tests/integration/test_replay_callback_url.py b/tests/integration/test_replay_callback_url.py new file mode 100644 index 00000000..2a0edc0b --- /dev/null +++ b/tests/integration/test_replay_callback_url.py @@ -0,0 +1,326 @@ +"""Replay integration tests: callbackUrl handling on buttons and modals. + +Port of replay-callback-url.test.ts (4 tests). + +When a button or modal carries a ``callback_url``, the SDK POSTs the action +payload to that URL in addition to firing any registered handler. These +tests replay real Slack webhook payloads (from tests/fixtures/replay/) +through the real ``SlackAdapter.handle_webhook()`` into a real ``Chat``, +with the SDK's encoded callback token (``__cb:``) and a stored modal +context with ``callbackUrl``, then assert the SDK resolves the URL and +POSTs the right shape. + +Discord adapter encoding/decoding is covered by unit tests in +``tests/test_discord_cards.py``. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from typing import Any +from unittest.mock import AsyncMock, patch +from urllib.parse import quote + +import pytest + +from chat_sdk.chat import Chat +from chat_sdk.testing import MockLogger, MockStateAdapter, create_mock_state +from chat_sdk.types import ActionEvent, ChatConfig, ModalSubmitEvent, WebhookOptions +from tests.fixtures.conftest import load_fixture + +try: + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + _SLACK_OK = True +except ImportError: + _SLACK_OK = False + +CALLBACK_BUTTON_URL = "https://hook.example.com/button-cb" +CALLBACK_MODAL_URL = "https://hook.example.com/modal-cb" +CALLBACK_TOKEN = "abcdef0123456789" +SLACK_SIGNING_SECRET = "test-signing-secret" + + +# --------------------------------------------------------------------------- +# Slack webhook plumbing (same conventions as tests/test_fixture_replay.py) +# --------------------------------------------------------------------------- + + +class _FakeRequest: + """Minimal request-like object for adapter webhook testing.""" + + def __init__(self, body: str, headers: dict[str, str] | None = None): + self._body = body.encode("utf-8") + self.headers = headers or {} + + async def text(self) -> str: + return self._body.decode("utf-8") + + +def _slack_signed_request(body: str, content_type: str = "application/json") -> _FakeRequest: + ts = str(int(time.time())) + sig_base = f"v0:{ts}:{body}" + sig = "v0=" + hmac.new(SLACK_SIGNING_SECRET.encode(), sig_base.encode(), hashlib.sha256).hexdigest() + return _FakeRequest( + body, + { + "x-slack-request-timestamp": ts, + "x-slack-signature": sig, + "content-type": content_type, + }, + ) + + +def _slack_interactive_request(payload: dict[str, Any]) -> _FakeRequest: + """Slack sends interactive payloads form-encoded as ``payload=``.""" + body = f"payload={quote(json.dumps(payload))}" + return _slack_signed_request(body, content_type="application/x-www-form-urlencoded") + + +class _SlackReplayContext: + """Real Chat + real SlackAdapter wired together (TS createSlackTestContext).""" + + def __init__(self, fixture: dict[str, Any]): + self.adapter = SlackAdapter( + SlackAdapterConfig( + signing_secret=SLACK_SIGNING_SECRET, + bot_token="xoxb-test-token", + bot_user_id=fixture.get("botUserId"), + ) + ) + self.state: MockStateAdapter = create_mock_state() + self.chat = Chat( + ChatConfig( + user_name=fixture.get("botName", "testbot"), + adapters={"slack": self.adapter}, + state=self.state, + logger=MockLogger(), + ) + ) + + async def send_webhook(self, payload: dict[str, Any]) -> dict[str, Any]: + """Send an event-callback webhook and wait for the spawned tasks.""" + tasks: list[Any] = [] + request = _slack_signed_request(json.dumps(payload)) + response = await self.chat.webhooks["slack"](request, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + return response + + async def send_interactive(self, payload: dict[str, Any]) -> dict[str, Any]: + """Send a block_actions / view_submission webhook and drain its tasks.""" + tasks: list[Any] = [] + request = _slack_interactive_request(payload) + response = await self.chat.webhooks["slack"](request, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + return response + + async def shutdown(self) -> None: + await self.chat.shutdown() + + +# =========================================================================== +# Slack button click with callback token +# =========================================================================== + + +@pytest.mark.skipif(not _SLACK_OK, reason="Slack adapter not available") +class TestSlackButtonClickWithCallbackToken: + """describe("Slack button click with callback token")""" + + # it("decodes the token, POSTs the URL, and passes the original value to onAction") + @pytest.mark.asyncio + async def test_decodes_the_token_posts_the_url_and_passes_the_original_value_to_onaction(self): + fixture = load_fixture("actions-reactions/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ActionEvent] = [] + + @ctx.chat.on_mention + async def on_mention(thread, message, context): + await thread.subscribe() + + ctx.chat.on_action(lambda event: captured.append(event)) + + try: + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_webhook(fixture["mention"]) + + # Pre-populate the callback URL store as the SDK would have + # done at post time. + await ctx.state.set( + f"chat:callback:{CALLBACK_TOKEN}", + {"url": CALLBACK_BUTTON_URL, "originalValue": "order-99"}, + ) + + # Synthesize a block_actions payload with the SDK's encoded + # token as the value. + action = { + **fixture["action"], + "actions": [ + { + **fixture["action"]["actions"][0], + "action_id": "approve", + "value": f"__cb:{CALLBACK_TOKEN}", + } + ], + } + + mock_fetch.reset_mock() + await ctx.send_interactive(action) + + # Handler sees the original value, not the encoded token. + assert len(captured) == 1 + assert captured[0].action_id == "approve" + assert captured[0].value == "order-99" + + # The SDK POSTed to the stored callback URL. + callback_calls = [c for c in mock_fetch.await_args_list if c.args[0] == CALLBACK_BUTTON_URL] + assert len(callback_calls) == 1 + + assert callback_calls[0].kwargs["method"] == "POST" + body = json.loads(callback_calls[0].kwargs["body"]) + assert body["type"] == "action" + assert body["actionId"] == "approve" + assert body["value"] == "order-99" + assert body["user"]["id"] == "U00FAKEUSER1" + finally: + await ctx.shutdown() + + # it("treats an unknown token as a regular value when nothing is stored") + @pytest.mark.asyncio + async def test_treats_an_unknown_token_as_a_regular_value_when_nothing_is_stored(self): + fixture = load_fixture("actions-reactions/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ActionEvent] = [] + + @ctx.chat.on_mention + async def on_mention(thread, message, context): + await thread.subscribe() + + ctx.chat.on_action(lambda event: captured.append(event)) + + try: + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_webhook(fixture["mention"]) + + action = { + **fixture["action"], + "actions": [ + { + **fixture["action"]["actions"][0], + "action_id": "approve", + "value": "__cb:not-a-real-token", + } + ], + } + + mock_fetch.reset_mock() + await ctx.send_interactive(action) + + # Handler still fires; value is preserved verbatim because no + # store entry exists. + assert len(captured) == 1 + assert captured[0].value == "__cb:not-a-real-token" + + # No fetch went to any callback URL. + callback_calls = [ + c for c in mock_fetch.await_args_list if str(c.args[0]).startswith("https://hook.example.com/") + ] + assert len(callback_calls) == 0 + finally: + await ctx.shutdown() + + +# =========================================================================== +# Slack modal submit with stored callbackUrl +# =========================================================================== + + +@pytest.mark.skipif(not _SLACK_OK, reason="Slack adapter not available") +class TestSlackModalSubmitWithStoredCallbackUrl: + """describe("Slack modal submit with stored callbackUrl")""" + + # it("POSTs the form values to the modal callbackUrl after the handler runs") + @pytest.mark.asyncio + async def test_posts_the_form_values_to_the_modal_callbackurl_after_the_handler_runs(self): + fixture = load_fixture("modals/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ModalSubmitEvent] = [] + + ctx.chat.on_modal_submit(lambda event: captured.append(event)) + + try: + context_id = fixture["modalContext"]["contextId"] + + # Simulate what openModal would have stored, including the + # callbackUrl. + await ctx.state.set( + f"modal-context:slack:{context_id}", + { + "thread": fixture["modalContext"]["thread"], + "message": fixture["modalContext"]["message"], + "callbackUrl": CALLBACK_MODAL_URL, + }, + ) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + response = await ctx.send_interactive(fixture["viewSubmission"]) + assert response["status"] == 200 + + # The user-provided handler still fires. + assert len(captured) == 1 + assert captured[0].callback_id == "feedback_form" + + # SDK POSTed the modal_submit payload to the stored URL. + callback_calls = [c for c in mock_fetch.await_args_list if c.args[0] == CALLBACK_MODAL_URL] + assert len(callback_calls) == 1 + + assert callback_calls[0].kwargs["method"] == "POST" + body = json.loads(callback_calls[0].kwargs["body"]) + assert body["type"] == "modal_submit" + assert body["callbackId"] == "feedback_form" + assert body["values"] == { + "message": "Hello!", + "category": "feature", + "email": "user@example.com", + } + finally: + await ctx.shutdown() + + # it("does not POST when the modal context lacks a callbackUrl") + @pytest.mark.asyncio + async def test_does_not_post_when_the_modal_context_lacks_a_callbackurl(self): + fixture = load_fixture("modals/slack.json") + ctx = _SlackReplayContext(fixture) + captured: list[ModalSubmitEvent] = [] + + ctx.chat.on_modal_submit(lambda event: captured.append(event)) + + try: + context_id = fixture["modalContext"]["contextId"] + + # Modal context exists but has no callbackUrl -- the existing flow. + await ctx.state.set( + f"modal-context:slack:{context_id}", + { + "thread": fixture["modalContext"]["thread"], + "message": fixture["modalContext"]["message"], + }, + ) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await ctx.send_interactive(fixture["viewSubmission"]) + + # The handler ran against the replayed payload, but nothing was + # POSTed to any callback URL. + assert len(captured) == 1 + callback_calls = [ + c for c in mock_fetch.await_args_list if str(c.args[0]).startswith("https://hook.example.com/") + ] + assert len(callback_calls) == 0 + finally: + await ctx.shutdown() diff --git a/tests/test_ai_tools.py b/tests/test_ai_tools.py new file mode 100644 index 00000000..cce9a6cb --- /dev/null +++ b/tests/test_ai_tools.py @@ -0,0 +1,817 @@ +"""Tests for ``chat_sdk.ai.tools``. + +Mirrors the upstream Vitest suite in +``packages/chat/src/ai/index.test.ts`` (vercel/chat#492). Each test is +load-bearing — exercising a specific contract of either the +``create_chat_tools`` orchestrator (presets, approval config, override +filtering) or a specific tool factory's ``execute`` path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from chat_sdk import Chat +from chat_sdk.ai import ( + ChatTool, + create_chat_tools, +) +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.shared.mock_adapter import ( + MockAdapter, + MockStateAdapter, + create_mock_adapter, + create_mock_state, + create_test_message, + mock_logger, +) +from chat_sdk.types import ( + ChannelInfo, + FetchResult, + ListThreadsResult, + PostableMarkdown, + PostableRaw, + ThreadInfo, + ThreadSummary, + UserInfo, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@dataclass +class _Harness: + chat: Chat + adapter: MockAdapter + state: MockStateAdapter + + +@pytest.fixture +async def harness() -> _Harness: + adapter = create_mock_adapter("slack") + state = create_mock_state() + chat = Chat( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=mock_logger, + ) + return _Harness(chat=chat, adapter=adapter, state=state) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +class TestCreateChatToolsShape: + """Tests for the ``create_chat_tools`` return shape, presets, and validation.""" + + async def test_returns_full_toolset_when_no_preset_supplied(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + assert sorted(tools.keys()) == sorted( + [ + "addReaction", + "deleteMessage", + "editMessage", + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + "postChannelMessage", + "postMessage", + "removeReaction", + "sendDirectMessage", + "startTyping", + "subscribeThread", + "unsubscribeThread", + ] + ) + + async def test_requires_a_chat_instance(self): + with pytest.raises(ChatError, match="requires a `chat` instance"): + create_chat_tools(chat=None) + + async def test_scopes_tools_to_single_preset(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset="reader") + names = sorted(tools.keys()) + assert names == sorted( + [ + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + ] + ) + # No write tools at all + assert "postMessage" not in names + assert "deleteMessage" not in names + + async def test_composes_multiple_presets(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset=["reader", "messenger"]) + names = set(tools.keys()) + assert "postMessage" in names + assert "fetchMessages" in names + assert "listThreads" in names + # Neither preset includes deleteMessage / editMessage + assert "deleteMessage" not in names + assert "editMessage" not in names + + async def test_rejects_unknown_preset_name(self, harness: _Harness): + with pytest.raises(ChatError, match="Unknown preset"): + create_chat_tools(chat=harness.chat, preset="superuser") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Approval semantics +# --------------------------------------------------------------------------- + + +class TestRequireApproval: + """Tests for the ``require_approval`` config (bool + per-tool mapping).""" + + async def test_every_write_tool_defaults_to_needs_approval_true(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is True, name + + async def test_read_only_tools_never_gate_on_approval(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + read_tools = [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + # Typing indicator is harmless and never gated + "startTyping", + ] + for name in read_tools: + assert tools[name].needs_approval is None, name + + async def test_disables_approval_on_every_write_tool_when_requireapproval_is_false(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is False, name + + async def test_per_tool_approval_overrides(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + require_approval={ + "postMessage": False, + "deleteMessage": True, + "subscribeThread": False, + }, + ) + assert tools["postMessage"].needs_approval is False + assert tools["deleteMessage"].needs_approval is True + assert tools["subscribeThread"].needs_approval is False + # Unspecified write tools fall back to True + assert tools["editMessage"].needs_approval is True + assert tools["unsubscribeThread"].needs_approval is True + + +# --------------------------------------------------------------------------- +# Override semantics +# --------------------------------------------------------------------------- + + +class TestOverrides: + """Tests for per-tool overrides (descriptions, extras, protected fields).""" + + async def test_applies_overrides_without_breaking_execution(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + overrides={ + "postMessage": { + "description": "Reply in the active support thread", + "needs_approval": False, + }, + }, + ) + assert tools["postMessage"].description == "Reply in the active support thread" + assert tools["postMessage"].needs_approval is False + + async def test_overrides_cannot_replace_core_tool_fields(self, harness: _Harness): + # Stash sentinels; if any of these leak through, the tool can't run. + hijack_execute = AsyncMock(return_value={"hijacked": True}) + hijack_input_schema: dict[str, Any] = {"sentinel": "input"} + hijack_output_schema: dict[str, Any] = {"sentinel": "output"} + input_examples = [ + {"input": {"threadId": "slack:C123:1234.5678", "message": "hello"}}, + ] + metadata = {"source": "chat-sdk"} + + tools = create_chat_tools( + chat=harness.chat, + require_approval=False, + overrides={ + "postMessage": { + "args": {"name": "custom"}, + "description": "Reply in the active support thread", + "execute": hijack_execute, + "id": "openai.custom", + "input_examples": input_examples, + "input_schema": hijack_input_schema, + "metadata": metadata, + "output_schema": hijack_output_schema, + "supports_deferred_results": True, + "type": "provider", + }, + }, + ) + tool = tools["postMessage"] + + # Description does come from overrides... + assert tool.description == "Reply in the active support thread" + # ...but the protected fields are filtered out so the real tool is intact. + assert tool.execute is not hijack_execute + assert tool.input_schema is not hijack_input_schema + # `args`, `id`, `output_schema`, `supports_deferred_results`, and `type` + # are protected fields — they never make it into `extras`. + for protected in ( + "args", + "id", + "output_schema", + "supports_deferred_results", + "type", + ): + assert protected not in tool.extras, protected + # Non-protected fields pass through to `extras` for the agent runtime. + assert tool.extras["input_examples"] == input_examples + assert tool.extras["metadata"] == metadata + + # The real execute still dispatches to the adapter. + result = await tool.execute({"threadId": "slack:C123:1234.5678", "message": "hello"}) + hijack_execute.assert_not_awaited() + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result == {"messageId": "msg-1", "threadId": "slack:C123:1234.5678"} + + +# --------------------------------------------------------------------------- +# Tool execute() paths +# --------------------------------------------------------------------------- + + +class TestExecutePaths: + """Each tool's ``execute()`` dispatches through to the right adapter call.""" + + async def test_postmessage_dispatches_via_the_adapters_postmessage(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postMessage"].execute( + {"threadId": "slack:C123:1234.5678", "message": "hello"}, + ) + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result["messageId"] == "msg-1" + + async def test_post_message_forwards_raw_postable_unchanged(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["postMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "message": {"raw": "..."}, + }, + ) + # The raw body must reach the adapter as a PostableRaw (not flattened to str). + assert len(harness.adapter._post_calls) == 1 + thread_id, sent = harness.adapter._post_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert isinstance(sent, PostableRaw) + assert sent.raw == "..." + + async def test_postchannelmessage_dispatches_via_the_adapters_postchannelmessage(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postChannelMessage"].execute( + {"channelId": "slack:C123", "message": {"markdown": "**hi**"}}, + ) + # ChannelImpl uses post_channel_message on adapters that support it + # (which MockAdapter does), so this must produce a SentMessage. + assert result["messageId"] == "msg-1" + assert result["threadId"] == "slack:C123" + + async def test_send_direct_message_opens_dm_then_posts(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["sendDirectMessage"].execute( + {"userId": "U123456", "message": "ping"}, + ) + # MockAdapter.open_dm produces `slack:DU123456:` — the DM thread id. + assert harness.adapter._post_calls == [("slack:DU123456:", "ping")] + + async def test_addreaction_dispatches_via_the_adapters_addreaction(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["addReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._add_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result["added"] is True + assert result["emoji"] == "thumbs_up" + + async def test_removereaction_dispatches_via_the_adapters_removereaction(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["removeReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._remove_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result == { + "removed": True, + "emoji": "thumbs_up", + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_deletemessage_dispatches_via_the_adapters_deletemessage(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["deleteMessage"].execute( + {"threadId": "slack:C123:1234.5678", "messageId": "msg-1"}, + ) + assert harness.adapter._delete_calls == [("slack:C123:1234.5678", "msg-1")] + assert result == { + "deleted": True, + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_editmessage_dispatches_via_the_adapters_editmessage(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["editMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "message": {"markdown": "**updated**"}, + }, + ) + assert len(harness.adapter._edit_calls) == 1 + thread_id, msg_id, postable = harness.adapter._edit_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert msg_id == "msg-1" + assert isinstance(postable, PostableMarkdown) + assert postable.markdown == "**updated**" + assert result["messageId"] == "msg-1" + + async def test_subscribe_thread_persists_subscription(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["subscribeThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + async def test_unsubscribe_thread_clears_subscription(self, harness: _Harness): + # Seed the state so we can prove unsubscribe clears it. + await harness.state.subscribe("slack:C123:1234.5678") + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["unsubscribeThread"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is False + assert result == {"subscribed": False, "threadId": "slack:C123:1234.5678"} + + async def test_starttyping_dispatches_via_the_adapters_starttyping(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + await tools["startTyping"].execute( + {"threadId": "slack:C123:1234.5678", "status": "Searching..."}, + ) + assert harness.adapter._start_typing_calls == [ + ("slack:C123:1234.5678", "Searching..."), + ] + + async def test_fetch_messages_projects_model_friendly_shape(self, harness: _Harness): + stub_message = create_test_message("m1", "hello") + harness.adapter.fetch_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor=None), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchMessages"].execute( + {"threadId": "slack:C123:1234.5678", "limit": 5, "direction": "backward"}, + ) + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "hello" + # Author is flattened into camelCase keys that match the wire shape. + assert result["messages"][0]["author"]["userName"] == "testuser" + + async def test_get_channel_info_returns_flattened_metadata(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C123"}) + assert result == { + "id": "slack:C123", + "name": "#slack:C123", + "isDM": False, + "memberCount": None, + "channelVisibility": None, + } + + async def test_fetchchannelmessages_dispatches_via_the_adapter_and_projects_messages(self, harness: _Harness): + stub_message = create_test_message("m1", "channel hello") + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor="next"), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchChannelMessages"].execute( + {"channelId": "slack:C123", "limit": 5, "direction": "backward"}, + ) + harness.adapter.fetch_channel_messages.assert_awaited_once() + call_args = harness.adapter.fetch_channel_messages.await_args + assert call_args.args[0] == "slack:C123" + # The FetchOptions are forwarded verbatim from the tool's inputs. + opts = call_args.args[1] + assert opts.limit == 5 + assert opts.cursor is None + assert opts.direction == "backward" + + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "channel hello" + assert result["nextCursor"] == "next" + + async def test_fetchchannelmessages_throws_when_the_adapter_does_not_support_it(self, harness: _Harness): + # Remove the adapter's fetch_channel_messages so the tool path's + # "does not support" branch fires. + harness.adapter.fetch_channel_messages = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages"): + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + + async def test_fetch_channel_messages_wraps_not_implemented(self, harness: _Harness): + # BaseAdapter's default stub for optional methods raises + # ChatNotImplementedError. The tool must wrap that into ChatError so + # callers see one consistent failure mode, preserving the cause chain. + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "fetch_channel_messages"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages") as exc_info: + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_fetch_thread_returns_flattened_thread_info(self, harness: _Harness): + harness.adapter.fetch_thread = AsyncMock( # type: ignore[method-assign] + return_value=ThreadInfo( + id="slack:C123:1234.5678", + channel_id="C123", + channel_name="#general", + channel_visibility="public", + is_dm=False, + metadata={}, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert result == { + "id": "slack:C123:1234.5678", + "channelId": "C123", + "channelName": "#general", + "channelVisibility": "public", + "isDM": False, + } + + async def test_listthreads_projects_threadsummary_entries(self, harness: _Harness): + root_message = create_test_message("m1", "root") + from datetime import datetime, timezone + + last_reply = datetime(2026, 3, 2, tzinfo=timezone.utc) + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + return_value=ListThreadsResult( + threads=[ + ThreadSummary( + id="slack:C123:1234.5678", + reply_count=4, + last_reply_at=last_reply, + root_message=root_message, + ), + ], + next_cursor=None, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute( + {"channelId": "slack:C123", "limit": 10}, + ) + assert len(result["threads"]) == 1 + summary = result["threads"][0] + assert summary["id"] == "slack:C123:1234.5678" + assert summary["replyCount"] == 4 + assert summary["lastReplyAt"] == last_reply.isoformat() + assert summary["rootMessage"]["id"] == "m1" + assert summary["rootMessage"]["text"] == "root" + + async def test_list_threads_uses_keyword_options(self, harness: _Harness): + """Pin that the tool passes ``options`` as a keyword to ``list_threads``. + + ``MockAdapter.list_threads`` (and any adapter using a ``**kwargs`` + signature) rejects a second positional arg with ``TypeError`` — the + tool must use the keyword form. This exercises the **real** + ``MockAdapter.list_threads`` (no ``AsyncMock`` override) so a + regression to positional args trips immediately at runtime, not just + in the mock-adapter ergonomics. + """ + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert result == {"threads": [], "nextCursor": None} + + async def test_listthreads_throws_when_the_adapter_does_not_support_it(self, harness: _Harness): + harness.adapter.list_threads = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads"): + await tools["listThreads"].execute({"channelId": "slack:C123"}) + + async def test_list_threads_wraps_not_implemented(self, harness: _Harness): + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "list_threads"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads") as exc_info: + await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_getthreadparticipants_delegates_to_threadgetparticipants_and_projects_authors( + self, harness: _Harness + ): + # Stub `chat.thread(...)` directly so we don't drag in the cursor + # pagination / current-message machinery just to test the projection. + from chat_sdk.types import Author as AuthorType + + participants_stub = [ + AuthorType( + user_id="UALICE1", + user_name="alice", + full_name="Alice", + is_bot=False, + is_me=False, + ), + AuthorType( + user_id="UBOB1", + user_name="bob", + full_name="Bob", + is_bot=False, + is_me=False, + ), + ] + + class _FakeThread: + async def get_participants(self) -> list[AuthorType]: + return participants_stub + + original_thread = harness.chat.thread + harness.chat.thread = lambda thread_id, **kwargs: _FakeThread() # type: ignore[assignment] + try: + tools = create_chat_tools(chat=harness.chat) + result = await tools["getThreadParticipants"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + finally: + harness.chat.thread = original_thread # type: ignore[assignment] + + assert result == { + "participants": [ + {"userId": "UALICE1", "userName": "alice", "fullName": "Alice", "isBot": False}, + {"userId": "UBOB1", "userName": "bob", "fullName": "Bob", "isBot": False}, + ], + } + + async def test_getuser_projects_userinfo_when_the_adapter_resolves_a_user(self, harness: _Harness): + harness.adapter.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="U123456", + user_name="alice", + full_name="Alice Doe", + email="alice@example.com", + is_bot=False, + avatar_url="https://example.com/a.png", + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "U123456"}) + assert result == { + "userId": "U123456", + "userName": "alice", + "fullName": "Alice Doe", + "email": "alice@example.com", + "isBot": False, + "avatarUrl": "https://example.com/a.png", + } + + async def test_get_user_returns_none_when_adapter_returns_none(self, harness: _Harness): + harness.adapter.get_user = AsyncMock(return_value=None) # type: ignore[method-assign] + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "UMISSING"}) + assert result is None + + +# --------------------------------------------------------------------------- +# Schema sanity checks +# --------------------------------------------------------------------------- + + +class TestInputSchemas: + """Schemas are part of the public contract — break them, break agent runtimes.""" + + async def test_every_tool_declares_an_input_schema_with_a_description(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + for name, tool in tools.items(): + assert isinstance(tool, ChatTool), name + assert tool.description, f"{name} is missing a description" + assert tool.input_schema.get("type") == "object", name + assert "properties" in tool.input_schema, name + + async def test_postable_input_schema_is_a_oneof_union(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + message_schema = tools["postMessage"].input_schema["properties"]["message"] + # The union must include all three branches: string, markdown, raw. + assert "oneOf" in message_schema + kinds: list[Any] = [] + for branch in message_schema["oneOf"]: + if branch.get("type") == "string": + kinds.append("string") + elif "properties" in branch and "markdown" in branch["properties"]: + kinds.append("markdown") + elif "properties" in branch and "raw" in branch["properties"]: + kinds.append("raw") + assert sorted(kinds) == ["markdown", "raw", "string"] + + +# --------------------------------------------------------------------------- +# Re-exports +# --------------------------------------------------------------------------- + + +class TestReexports: + """The ``chat_sdk.ai`` package re-exports the tool factory surface.""" + + async def test_can_import_individual_factory(self, harness: _Harness): + # If individual tool factories aren't exported, downstream code that + # cherry-picks (``from chat_sdk.ai import post_message``) breaks. + from chat_sdk.ai import add_reaction, post_message + + tool = post_message(harness.chat) + assert isinstance(tool, ChatTool) + assert tool.needs_approval is True + # The needs_approval default propagates through the factory's ToolOptions. + + from chat_sdk.ai.tools import ToolOptions + + relaxed = add_reaction(harness.chat, ToolOptions(needs_approval=False)) + assert relaxed.needs_approval is False + + async def test_messages_helpers_still_importable_from_ai(self): + # PR 1 of the chat/ai port moved to_ai_messages here; if the new + # tool exports clobber that, the re-export goes silently stale. + from chat_sdk.ai import to_ai_messages + + assert callable(to_ai_messages) + + +# --------------------------------------------------------------------------- +# Approval gating quirks +# --------------------------------------------------------------------------- + + +class TestApprovalEdgeCases: + """Edge cases for the approval mapping that aren't covered above.""" + + async def test_partial_mapping_falls_back_to_true_for_unspecified_writes(self, harness: _Harness): + # Only override one tool — every other write tool should keep the + # default needs_approval=True. A regression that flips the default + # to False would silently let untrusted models post messages. + tools = create_chat_tools( + chat=harness.chat, + require_approval={"postMessage": False}, + ) + assert tools["postMessage"].needs_approval is False + # Every other write tool still needs approval. + for name in ( + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ): + assert tools[name].needs_approval is True, name + + +# --------------------------------------------------------------------------- +# Channel info edge cases (covers ChannelInfo.is_dm branching) +# --------------------------------------------------------------------------- + + +class TestChannelInfoEdgeCases: + async def test_get_channel_info_defaults_is_dm_false_when_adapter_returns_none(self, harness: _Harness): + # ChannelInfo.is_dm is Optional; the tool must coerce None → False + # so the model sees a plain boolean instead of a missing field. + harness.adapter.fetch_channel_info = AsyncMock( # type: ignore[method-assign] + return_value=ChannelInfo(id="slack:C999", name=None, is_dm=None, metadata={}), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C999"}) + assert result["isDM"] is False + assert result["name"] is None + + +# --------------------------------------------------------------------------- +# Schema isolation (review): tools must not share mutable nested schema dicts +# --------------------------------------------------------------------------- + + +class TestSchemaIsolation: + """Each tool must own a fully independent ``input_schema``. + + The factories build several tools from the same shared schema source + (the postable-message body, the fetch ``direction`` enum). If two tools + embedded the *same* nested dict object, a downstream consumer mutating + one tool's schema in place would silently corrupt its siblings. These + tests deep-mutate one tool's schema and assert the sibling is untouched, + pinning the per-tool deep-copy guarantee. + """ + + async def test_postable_message_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + post_msg = tools["postMessage"] + post_channel = tools["postChannelMessage"] + + a = post_msg.input_schema["properties"]["message"] + b = post_channel.input_schema["properties"]["message"] + # Distinct top-level objects and distinct nested objects. + assert a is not b + assert a["oneOf"] is not b["oneOf"] + assert a["oneOf"][1] is not b["oneOf"][1] + + # Deep-mutate one tool's nested schema; the sibling must be unaffected. + a["oneOf"][1]["properties"]["markdown"]["description"] = "MUTATED" + a["oneOf"].append({"type": "null"}) + assert "description" not in b["oneOf"][1]["properties"]["markdown"] + assert len(b["oneOf"]) == 3 + + # A freshly built toolset must also be pristine (no module-level bleed). + fresh = create_chat_tools(chat=harness.chat) + fresh_msg = fresh["postMessage"].input_schema["properties"]["message"] + assert "description" not in fresh_msg["oneOf"][1]["properties"]["markdown"] + assert len(fresh_msg["oneOf"]) == 3 + + async def test_fetch_direction_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + fetch_msgs = tools["fetchMessages"] + fetch_channel = tools["fetchChannelMessages"] + + a = fetch_msgs.input_schema["properties"]["direction"] + b = fetch_channel.input_schema["properties"]["direction"] + assert a is not b + # The enum list is a nested mutable object that must not be shared. + assert a["enum"] is not b["enum"] + + # Deep-mutate one tool's direction enum; sibling must be unaffected. + a["enum"].append("sideways") + assert b["enum"] == ["forward", "backward"] + + fresh = create_chat_tools(chat=harness.chat) + fresh_dir = fresh["fetchMessages"].input_schema["properties"]["direction"] + assert fresh_dir["enum"] == ["forward", "backward"] diff --git a/tests/test_callback_url.py b/tests/test_callback_url.py new file mode 100644 index 00000000..6a4a683c --- /dev/null +++ b/tests/test_callback_url.py @@ -0,0 +1,324 @@ +"""Faithful translation of callback-url.test.ts (17 tests). + +Each ``it("...")`` block from the TypeScript test suite is translated +to a corresponding ``async def test_...`` method, preserving the same +inputs, assertions, and test structure. + +TS stubs the global ``fetch``; Python patches the ``_fetch`` seam in +``chat_sdk.callback_url`` (the lazy aiohttp wrapper). + +TS file: packages/chat/src/callback-url.test.ts +""" + +from __future__ import annotations + +import copy +import json +import re +from unittest.mock import AsyncMock, call, patch + +from chat_sdk.callback_url import ( + decode_callback_value, + encode_callback_value, + post_to_callback_url, + process_card_callback_urls, + resolve_callback_url, +) +from chat_sdk.cards import Actions, Button, Card, CardText, Section +from chat_sdk.testing import MockStateAdapter, create_mock_state + +CALLBACK_TOKEN_PATTERN = re.compile(r"^__cb:[a-f0-9]{16}$") +CALLBACK_PREFIX_PATTERN = re.compile(r"^__cb:") + + +# =========================================================================== +# encodeCallbackValue / decodeCallbackValue +# =========================================================================== + + +class TestEncodeDecodeCallbackValue: + """describe("encodeCallbackValue / decodeCallbackValue")""" + + # it("encodes token") + def test_encodes_token(self): + encoded = encode_callback_value("abc123") + assert encoded == "__cb:abc123" + + # it("decodes token from encoded value") + def test_decodes_token_from_encoded_value(self): + decoded = decode_callback_value("__cb:abc123") + assert decoded.callback_token == "abc123" + + # it("returns no token for regular values") + def test_returns_no_token_for_regular_values(self): + decoded = decode_callback_value("regular-value") + assert decoded.callback_token is None + + # it("returns no token for undefined value") + def test_returns_no_token_for_undefined_value(self): + decoded = decode_callback_value(None) + assert decoded.callback_token is None + + # it("round-trips encode/decode") + def test_roundtrips_encodedecode(self): + encoded = encode_callback_value("tok123") + decoded = decode_callback_value(encoded) + assert decoded.callback_token == "tok123" + + +# =========================================================================== +# processCardCallbackUrls +# =========================================================================== + + +class TestProcessCardCallbackUrls: + """describe("processCardCallbackUrls")""" + + def _state(self) -> MockStateAdapter: + return create_mock_state() + + # it("returns card unchanged when no buttons have callbackUrl") + async def test_returns_card_unchanged_when_no_buttons_have_callbackurl(self): + state = self._state() + card = Card( + title="Test", + children=[ + CardText("Hello"), + Actions([Button(id="btn", label="Click")]), + ], + ) + + result = await process_card_callback_urls(card, state) + assert result is card + + # it("encodes callbackUrl into button value and stores in state") + async def test_encodes_callbackurl_into_button_value_and_stores_in_state(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="approve", + label="Approve", + callback_url="https://example.com/webhook/123", + ) + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + + actions = next(c for c in result["children"] if c["type"] == "actions") + button = actions["children"][0] + assert button["type"] == "button" + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + assert "callback_url" not in button + + decoded = decode_callback_value(button["value"]) + assert decoded.callback_token is not None + + resolved = await resolve_callback_url(decoded.callback_token, state) + assert resolved is not None + assert resolved.url == "https://example.com/webhook/123" + + # it("stores original value in state alongside callback URL") + async def test_stores_original_value_in_state_alongside_callback_url(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="btn", + label="Go", + value="item-99", + callback_url="https://hook.example.com", + ) + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + button = next(c for c in result["children"] if c["type"] == "actions")["children"][0] + + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + + decoded = decode_callback_value(button["value"]) + resolved = await resolve_callback_url(decoded.callback_token or "", state) + assert resolved is not None + assert resolved.url == "https://hook.example.com" + assert resolved.original_value == "item-99" + + # it("only processes buttons with callbackUrl, leaves others untouched") + async def test_only_processes_buttons_with_callbackurl_leaves_others_untouched(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button(id="normal", label="Normal", value="keep"), + Button( + id="callback", + label="Callback", + callback_url="https://example.com", + ), + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + actions = next(c for c in result["children"] if c["type"] == "actions") + normal_btn = actions["children"][0] + callback_btn = actions["children"][1] + + assert normal_btn["value"] == "keep" + assert CALLBACK_PREFIX_PATTERN.match(callback_btn["value"]) + + # it("processes buttons nested inside sections") + async def test_processes_buttons_nested_inside_sections(self): + state = self._state() + card = Card( + title="Test", + children=[ + Section( + [ + CardText("Nested"), + Actions( + [ + Button( + id="nested-btn", + label="Go", + callback_url="https://example.com/nested", + ) + ] + ), + ] + ), + ], + ) + + result = await process_card_callback_urls(card, state) + section = next(c for c in result["children"] if c["type"] == "section") + actions = next(c for c in section["children"] if c["type"] == "actions") + button = actions["children"][0] + assert button["type"] == "button" + + assert CALLBACK_TOKEN_PATTERN.match(button["value"]) + assert "callback_url" not in button + + decoded = decode_callback_value(button["value"]) + resolved = await resolve_callback_url(decoded.callback_token or "", state) + assert resolved is not None + assert resolved.url == "https://example.com/nested" + + # it("does not mutate the original card") + async def test_does_not_mutate_the_original_card(self): + state = self._state() + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="btn", + label="Go", + callback_url="https://example.com", + ) + ] + ), + ], + ) + + original = copy.deepcopy(card) + await process_card_callback_urls(card, state) + assert card == original + + +# =========================================================================== +# resolveCallbackUrl +# =========================================================================== + + +class TestResolveCallbackUrl: + """describe("resolveCallbackUrl")""" + + # it("returns null for unknown token") + async def test_returns_null_for_unknown_token(self): + state = create_mock_state() + result = await resolve_callback_url("nonexistent", state) + assert result is None + + # it("resolves stored callback with URL and original value") + async def test_resolves_stored_callback_with_url_and_original_value(self): + state = create_mock_state() + await state.set( + "chat:callback:test-token", + {"url": "https://example.com/hook", "originalValue": "item-42"}, + ) + result = await resolve_callback_url("test-token", state) + assert result is not None + assert result.url == "https://example.com/hook" + assert result.original_value == "item-42" + + # it("handles legacy string format") + async def test_handles_legacy_string_format(self): + state = create_mock_state() + await state.set("chat:callback:legacy-token", "https://example.com/hook") + result = await resolve_callback_url("legacy-token", state) + assert result is not None + assert result.url == "https://example.com/hook" + assert result.original_value is None + + +# =========================================================================== +# postToCallbackUrl +# =========================================================================== + + +class TestPostToCallbackUrl: + """describe("postToCallbackUrl")""" + + # it("POSTs JSON payload to the URL") + async def test_posts_json_payload_to_the_url(self): + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as fetch_mock: + result = await post_to_callback_url( + "https://example.com/hook", + {"type": "action", "actionId": "approve"}, + ) + + assert result.error is None + assert result.status == 200 + assert fetch_mock.await_args == call( + "https://example.com/hook", + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps({"type": "action", "actionId": "approve"}), + ) + + # it("returns error for non-2xx responses") + async def test_returns_error_for_non2xx_responses(self): + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(404, "Not Found"))): + result = await post_to_callback_url("https://example.com/hook", {}) + + assert isinstance(result.error, Exception) + assert "Callback URL returned 404: Not Found" in str(result.error) + assert result.status == 404 + + # it("catches fetch errors and returns them") + async def test_catches_fetch_errors_and_returns_them(self): + with patch( + "chat_sdk.callback_url._fetch", + new=AsyncMock(side_effect=Exception("Network error")), + ): + result = await post_to_callback_url("https://example.com/hook", {}) + + assert isinstance(result.error, Exception) + assert str(result.error) == "Network error" + assert result.status is None diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index ac74bec1..d14b1f2c 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -17,6 +17,8 @@ import pytest +from chat_sdk.callback_url import decode_callback_value +from chat_sdk.cards import Actions, Button, Card from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter, derive_channel_id from chat_sdk.errors import ChatNotImplementedError from chat_sdk.testing import ( @@ -29,12 +31,14 @@ from chat_sdk.thread import ThreadImpl, _ThreadImplConfig from chat_sdk.types import ( Attachment, + EphemeralMessage, FetchResult, ListThreadsResult, Message, PostableAst, PostableMarkdown, PostableRaw, + PostEphemeralOptions, RawMessage, ScheduledMessage, ThreadSummary, @@ -752,7 +756,7 @@ def test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails(self): "_adapter": original._adapter, "_adapter_name": original._adapter_name, "_state_adapter_instance": original._state_adapter_instance, - "_message_history": original._message_history, + "_thread_history": original._thread_history, } with pytest.raises(RuntimeError, match='Adapter "slack" not found'): @@ -1381,3 +1385,199 @@ class TestJsxAbsorbers: # test cannot be faithfully translated. Kept as an absorber for verify_test_fidelity.py. def test_should_convert_jsx_card_elements_to_cardelement(self): assert True + + +# =========================================================================== +# callbackUrl processing (vercel/chat#454) +# =========================================================================== + + +class TestCallbackUrlProcessing: + """describe("callbackUrl processing") from channel.test.ts""" + + FUTURE_DATE = datetime(2030, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def _make_tracked_channel(self) -> tuple[ChannelImpl, MockAdapter, MockStateAdapter, list[tuple[str, Any]]]: + adapter = create_mock_adapter() + state = create_mock_state() + post_calls: list[tuple[str, Any]] = [] + + async def tracking_post(channel_id: str, message: Any) -> RawMessage: + post_calls.append((channel_id, message)) + return RawMessage(id="msg-1", thread_id=channel_id, raw={}) + + adapter.post_channel_message = tracking_post # type: ignore[assignment] + channel = _make_channel(adapter, state) + return channel, adapter, state, post_calls + + def _callback_keys(self, state: MockStateAdapter) -> list[str]: + return [k for k in state.cache if k.startswith("chat:callback:")] + + # it("should encode callbackUrl tokens when posting a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_tokens_when_posting_a_card(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + card = Card( + title="Test", + children=[ + Actions( + [ + Button( + id="approve", + label="Approve", + callback_url="https://example.com/hook", + ) + ] + ), + ], + ) + + await channel.post(card) + + posted_card = post_calls[0][1] + actions = next(c for c in posted_card["children"] if c["type"] == "actions") + button = actions["children"][0] + + decoded = decode_callback_value(button["value"]) + assert decoded.callback_token is not None + assert "callback_url" not in button + + stored = await state.get(f"chat:callback:{decoded.callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/hook" + + # it("should encode callbackUrl when posting via postEphemeral") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_via_postephemeral(self): + adapter = create_mock_adapter() + state = create_mock_state() + mock_post_ephemeral = AsyncMock( + return_value=EphemeralMessage(id="e1", thread_id="slack:C123", used_fallback=False, raw={}) + ) + adapter.post_ephemeral = mock_post_ephemeral # type: ignore[attr-defined] + channel = _make_channel(adapter, state) + + await channel.post_ephemeral( + "U1", + Card( + children=[ + Actions( + [ + Button( + id="ack", + label="Ack", + callback_url="https://example.com/eph", + ) + ] + ), + ] + ), + PostEphemeralOptions(fallback_to_dm=False), + ) + + sent_card = mock_post_ephemeral.call_args[0][2] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/eph" + + # it("should encode callbackUrl when scheduling") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_scheduling(self): + adapter = create_mock_adapter() + state = create_mock_state() + adapter.schedule_message = AsyncMock( # type: ignore[attr-defined] + return_value=ScheduledMessage( + scheduled_message_id="Q1", + channel_id="C123", + post_at=self.FUTURE_DATE, + raw={}, + _cancel=AsyncMock(return_value=None), + ) + ) + channel = _make_channel(adapter, state) + + await channel.schedule( + Card( + children=[ + Actions( + [ + Button( + id="later", + label="Later", + callback_url="https://example.com/sch", + ) + ] + ), + ] + ), + post_at=self.FUTURE_DATE, + ) + + sent_card = adapter.schedule_message.call_args[0][1] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/sch" + + # it("should encode callbackUrl when editing a sent card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_editing_a_sent_card(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + sent = await channel.post("Hello") + await sent.edit( + Card( + children=[ + Actions( + [ + Button( + id="redo", + label="Redo", + callback_url="https://example.com/edit", + ) + ] + ), + ] + ) + ) + + edited_card = adapter._edit_calls[0][2] + button = edited_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/edit" + + # it("should pass plain string posts through unchanged") + @pytest.mark.asyncio + async def test_should_pass_plain_string_posts_through_unchanged(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + await channel.post("Just text") + + assert post_calls == [("slack:C123", "Just text")] + assert self._callback_keys(state) == [] + + # it("should leave cards without callback buttons untouched") + @pytest.mark.asyncio + async def test_should_leave_cards_without_callback_buttons_untouched(self): + channel, adapter, state, post_calls = self._make_tracked_channel() + + await channel.post( + Card( + children=[ + Actions([Button(id="ok", label="OK", value="keep")]), + ] + ) + ) + + posted_card = post_calls[0][1] + assert posted_card["children"][0]["children"][0]["value"] == "keep" + assert self._callback_keys(state) == [] diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index bb5c491c..6d650f5c 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -10,8 +10,10 @@ from __future__ import annotations import asyncio +import json import re from typing import Any +from unittest.mock import AsyncMock, patch import pytest @@ -34,10 +36,12 @@ ConcurrencyConfig, EmojiValue, MessageContext, + ModalResponse, ModalSubmitEvent, QueueEntry, ReactionEvent, SlashCommandEvent, + WebhookOptions, ) HELP_REGEX = re.compile(r"help", re.IGNORECASE) @@ -3852,13 +3856,55 @@ async def handler(thread, message, context=None): # ============================================================================ -# 23. persistMessageHistory (tests 92-93) +# 23. persistThreadHistory (tests 92-97) # ============================================================================ -class TestPersistMessageHistory: - # TS: "should cache incoming messages when adapter has persistMessageHistory" - async def test_should_cache_incoming_messages_when_adapter_has_persistmessagehistory(self): +def _record_append_to_list_calls(state: MockStateAdapter) -> list[tuple[str, Any, int | None, int | None]]: + """Wrap ``state.append_to_list`` to record ``(key, value, max_length, ttl_ms)``. + + Python stand-in for the TS mock state's ``vi.fn()``-wrapped + ``appendToList`` that the upstream config-precedence tests assert on. + """ + calls: list[tuple[str, Any, int | None, int | None]] = [] + real_append = state.append_to_list + + async def _recording_append( + key: str, value: Any, *, max_length: int | None = None, ttl_ms: int | None = None + ) -> None: + calls.append((key, value, max_length, ttl_ms)) + await real_append(key, value, max_length=max_length, ttl_ms=ttl_ms) + + state.append_to_list = _recording_append + return calls + + +class TestPersistThreadHistory: + # TS: "caches incoming messages when adapter sets persistThreadHistory" + async def test_caches_incoming_messages_when_adapter_sets_persistthreadhistory(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello from WhatsApp") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + stored = state.cache.get("msg-history:whatsapp:phone:user1") + assert stored is not None + assert isinstance(stored, list) + assert stored[0]["id"] == "msg-1" + + # TS: "caches incoming messages when adapter sets the deprecated persistMessageHistory flag" + async def test_caches_incoming_messages_when_adapter_sets_the_deprecated_persistmessagehistory_flag(self): adapter = create_mock_adapter("whatsapp") adapter.persist_message_history = True state = create_mock_state() @@ -3880,8 +3926,8 @@ async def test_should_cache_incoming_messages_when_adapter_has_persistmessagehis assert isinstance(stored, list) assert stored[0]["id"] == "msg-1" - # TS: "should NOT cache incoming messages when adapter does not set persistMessageHistory" - async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_persistmessagehistory(self): + # TS: "does not cache when adapter sets neither flag" + async def test_does_not_cache_when_adapter_sets_neither_flag(self): chat, adapter, state = await _init_chat() msg = create_test_message("msg-2", "Hello from Slack") @@ -3889,3 +3935,699 @@ async def test_should_not_cache_incoming_messages_when_adapter_does_not_set_pers history_keys = [k for k in state.cache if k.startswith("msg-history:")] assert len(history_keys) == 0 + + # TS: "honors top-level config.messageHistory (deprecated alias) when threadHistory is not set" + async def test_honors_top_level_config_messagehistory_deprecated_alias_when_threadhistory_is_not_set(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + append_calls = _record_append_to_list_calls(state) + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + message_history={"max_messages": 5, "ttl_ms": 12_345}, + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + matching = [call for call in append_calls if call[0] == "msg-history:whatsapp:phone:user1"] + assert len(matching) == 1 + _key, value, max_length, ttl_ms = matching[0] + assert value["id"] == "msg-1" + assert max_length == 5 + assert ttl_ms == 12_345 + + # TS: "threadHistory takes precedence when both threadHistory and messageHistory are set" + async def test_threadhistory_takes_precedence_when_both_threadhistory_and_messagehistory_are_set(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + state = create_mock_state() + append_calls = _record_append_to_list_calls(state) + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + thread_history={"max_messages": 5, "ttl_ms": 1_000}, + message_history={"max_messages": 999, "ttl_ms": 999_999}, + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + matching = [call for call in append_calls if call[0] == "msg-history:whatsapp:phone:user1"] + assert len(matching) == 1 + _key, value, max_length, ttl_ms = matching[0] + assert value["id"] == "msg-1" + assert max_length == 5 + assert ttl_ms == 1_000 + + # TS: "persists when both persistThreadHistory and persistMessageHistory are set on the adapter" + async def test_persists_when_both_persistthreadhistory_and_persistmessagehistory_are_set_on_the_adapter(self): + adapter = create_mock_adapter("whatsapp") + adapter.persist_thread_history = True + adapter.persist_message_history = True + state = create_mock_state() + + config = ChatConfig( + user_name="testbot", + adapters={"whatsapp": adapter}, + state=state, + logger=MockLogger(), + ) + chat = Chat(config) + await chat.webhooks["whatsapp"]("request") + + msg = create_test_message("msg-1", "Hello") + await chat.handle_incoming_message(adapter, "whatsapp:phone:user1", msg) + + stored = state.cache.get("msg-history:whatsapp:phone:user1") + assert stored is not None + assert stored[0]["id"] == "msg-1" + + +# ============================================================================ +# 21. processMessage return value (core slice of vercel/chat#444) +# ============================================================================ + + +class TestProcessMessageAwaitable: + """Faithful port of the TS root-describe test added by vercel/chat#444. + + Only the core ``processMessage`` slice of #444 applies to the Python + port — the ``@chat-adapter/web`` package itself is browser-only and not + ported (see docs/UPSTREAM_SYNC.md). + """ + + # TS: "should return an awaitable Promise from processMessage" + async def test_should_return_an_awaitable_promise_from_processmessage(self): + chat, adapter, _state = await _init_chat() + + handler_started = asyncio.Event() + release_handler = asyncio.Event() + + @chat.on_mention + async def handler(thread, message, context=None): + handler_started.set() + await release_handler.wait() + + message = create_test_message("msg-await-1", "Hey @slack-bot ping") + task = chat.process_message(adapter, "slack:C123:await.1", message) + + assert isinstance(task, asyncio.Task) + + # Yield so the handler kicks off but cannot complete until released. + await asyncio.wait_for(handler_started.wait(), timeout=1) + assert not task.done() + + release_handler.set() + await asyncio.wait_for(task, timeout=1) + assert task.done() + + +# ============================================================================ +# 22. queue/burst subject rehydration after a JSON roundtrip (vercel/chat#459 +# + #495). Gated on message.subject (PR #131): the skipif below evaluates +# BaseAdapter.fetch_subject at collection time, so these activate +# automatically once #131 merges — no manual unskip step. +# ============================================================================ + + +def _wrap_enqueue_with_json_roundtrip(state: MockStateAdapter) -> None: + """Make ``state.enqueue`` JSON-roundtrip entries like a persistent backend. + + TS does ``JSON.parse(JSON.stringify(entry))``; the Python equivalent of + what Redis/Postgres do to a queued message is ``Message.to_json()`` → + JSON → plain dict (``Chat._rehydrate_message`` upgrades it on dequeue). + """ + import json + + real_enqueue = state.enqueue + + async def wrapped(thread_id: str, entry: QueueEntry, max_size: int) -> int: + plain = json.loads(json.dumps(entry.message.to_json())) + roundtripped = QueueEntry( + message=plain, # type: ignore[arg-type] + enqueued_at=entry.enqueued_at, + expires_at=entry.expires_at, + ) + return await real_enqueue(thread_id, roundtripped, max_size) + + state.enqueue = wrapped # type: ignore[method-assign] + + +def _base_adapter_has_fetch_subject() -> bool: + from chat_sdk.types import BaseAdapter + + return hasattr(BaseAdapter, "fetch_subject") + + +requires_fetch_subject = pytest.mark.skipif( + not _base_adapter_has_fetch_subject(), + reason="message.subject infrastructure lands with PR #131; this fidelity " + "port auto-unskips once BaseAdapter.fetch_subject exists", +) + + +class TestConcurrencyQueueSubjectRehydration: + # TS: "should wire adapter on queued message so fetchSubject works after JSON roundtrip" + @requires_fetch_subject + async def test_should_wire_adapter_on_queued_message_so_fetchsubject_works_after_json_roundtrip( + self, + ): + from unittest.mock import AsyncMock + + state = create_mock_state() + _wrap_enqueue_with_json_roundtrip(state) + adapter = create_mock_adapter("slack") + mock_subject = {"type": "issue", "id": "ENG-1", "title": "Queue test", "raw": {}} + adapter.fetch_subject = AsyncMock(return_value=mock_subject) # type: ignore[attr-defined] + + chat, _, _ = await _init_chat(adapter=adapter, state=state, concurrency="queue") + + received_subject: Any = "not-called" + + @chat.on_mention + async def handler(thread, message, context=None): + nonlocal received_subject + received_subject = await message.subject + + await state.acquire_lock("slack:C123:1234.5678", 30000) + msg = create_test_message("msg-sub-q", "Hey @slack-bot test") + await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", msg) + + await state.force_release_lock("slack:C123:1234.5678") + trigger = create_test_message("msg-sub-trigger", "Hey @slack-bot go") + await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", trigger) + + assert received_subject == mock_subject + assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] + + +class TestConcurrencyBurstRehydration: + # TS: "should rehydrate queued messages after JSON roundtrip" + @requires_fetch_subject + async def test_should_rehydrate_queued_messages_after_json_roundtrip(self): + from unittest.mock import AsyncMock + + state = create_mock_state() + _wrap_enqueue_with_json_roundtrip(state) + adapter = create_mock_adapter("slack") + mock_subject = {"type": "issue", "id": "ENG-414", "title": "Burst test", "raw": {}} + adapter.fetch_subject = AsyncMock(return_value=mock_subject) # type: ignore[attr-defined] + + chat, _, _ = await _init_chat( + adapter=adapter, + state=state, + concurrency=ConcurrencyConfig(strategy="burst", debounce_ms=80), + ) + + received_subject: Any = "not-called" + received_skipped_subject: Any = "not-called" + + @chat.on_mention + async def handler(thread, message, context=None): + nonlocal received_subject, received_skipped_subject + received_subject = await message.subject + if context is not None and context.skipped: + received_skipped_subject = await context.skipped[0].subject + + task = asyncio.create_task( + chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-burst-json-1", "Hey @slack-bot one"), + ) + ) + await asyncio.sleep(0.005) + await chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-burst-json-2", "Hey @slack-bot two"), + ) + await task + + assert received_subject == mock_subject + assert received_skipped_subject == mock_subject + assert adapter.fetch_subject.await_count == 2 # type: ignore[attr-defined] + + +# ============================================================================ +# 24. Action callbackUrl handling (vercel/chat#454) +# ============================================================================ + + +def _make_callback_chat() -> tuple[Chat, MockAdapter, MockStateAdapter, MockLogger]: + """Like _make_chat but returns the logger so error logs can be asserted.""" + adapter = create_mock_adapter("slack") + state = create_mock_state() + logger = MockLogger() + config = ChatConfig( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=logger, + ) + return Chat(config), adapter, state, logger + + +async def _process_action_and_wait(chat: Chat, event: ActionEvent) -> None: + """Dispatch an action and deterministically wait for the handler task.""" + tasks: list[Any] = [] + chat.process_action(event, WebhookOptions(wait_until=tasks.append)) + await asyncio.gather(*tasks) + + +class TestActionsCallbackUrl: + """TS describe("processAction") — callbackUrl additions.""" + + # TS: "should decode callbackUrl token and POST to it" + async def test_should_decode_callbackurl_token_and_post_to_it(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + async def _handler(event): + received.append(event) + + chat.on_action("approve", _handler) + + state.cache["chat:callback:testtoken123"] = { + "url": "https://example.com/webhook/hook1", + "originalValue": "order-789", + } + + event = _make_action_event(adapter, action_id="approve", value="__cb:testtoken123") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value == "order-789" + + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/hook1",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + assert mock_fetch.await_args.kwargs["headers"] == {"Content-Type": "application/json"} + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert body == { + "type": "action", + "actionId": "approve", + "value": "order-789", + "user": {"id": "U123", "name": "user"}, + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + } + + # TS: "should decode callbackUrl token with no original value" + async def test_should_decode_callbackurl_token_with_no_original_value(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + chat.on_action(lambda event: received.append(event)) + + state.cache["chat:callback:tok999"] = {"url": "https://example.com/webhook/hook2"} + + event = _make_action_event(adapter, action_id="deny", value="__cb:tok999") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value is None + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert "value" not in body + + # TS: "should preserve callback-like values when no callbackUrl is stored" + async def test_should_preserve_callbacklike_values_when_no_callbackurl_is_stored(self): + chat, adapter, state = await _init_chat() + received: list[ActionEvent] = [] + + async def _handler(event): + received.append(event) + + chat.on_action("approve", _handler) + + event = _make_action_event(adapter, action_id="approve", value="__cb:not-a-stored-token") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(received) == 1 + assert received[0].value == "__cb:not-a-stored-token" + assert mock_fetch.await_count == 0 + + # TS: "should fire onAction handlers alongside callbackUrl POST" + async def test_should_fire_onaction_handlers_alongside_callbackurl_post(self): + chat, adapter, state = await _init_chat() + catch_all_calls: list[ActionEvent] = [] + specific_calls: list[ActionEvent] = [] + + chat.on_action(lambda event: catch_all_calls.append(event)) + + async def _specific(event): + specific_calls.append(event) + + chat.on_action("approve", _specific) + + state.cache["chat:callback:tok555"] = {"url": "https://example.com/webhook/hook3"} + + event = _make_action_event(adapter, action_id="approve", value="__cb:tok555") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await _process_action_and_wait(chat, event) + + assert len(catch_all_calls) == 1 + assert len(specific_calls) == 1 + assert mock_fetch.await_count == 1 + + +# ============================================================================ +# 25. Modal callbackUrl handling (vercel/chat#454) +# ============================================================================ + + +def _make_modal_submit_event( + adapter: MockAdapter, + *, + callback_id: str = "feedback_modal", + view_id: str = "V789", + values: dict[str, str] | None = None, +) -> ModalSubmitEvent: + return ModalSubmitEvent( + callback_id=callback_id, + view_id=view_id, + values=values if values is not None else {}, + user=_make_author(), + adapter=adapter, + raw={}, + ) + + +class TestModalCallbackUrl: + """TS describe("processModalSubmit") — callbackUrl additions.""" + + # TS: "should POST to modal callbackUrl on submit" + async def test_should_post_to_modal_callbackurl_on_submit(self): + chat, adapter, state = await _init_chat() + captured_action: list[ActionEvent] = [] + + async def _action_handler(event): + captured_action.append(event) + + chat.on_action("open_form", _action_handler) + + action_event = _make_action_event(adapter, action_id="open_form", trigger_id="trigger-123") + await _process_action_and_wait(chat, action_event) + + modal = { + "type": "modal", + "callback_id": "feedback_modal", + "title": "Feedback", + "callback_url": "https://example.com/webhook/modal-hook", + "children": [], + } + assert len(captured_action) == 1 + await captured_action[0].open_modal(modal) + + modal_context_keys = [k for k in state.cache if k.startswith("modal-context:")] + assert len(modal_context_keys) == 1 + context_id = modal_context_keys[0].split(":")[-1] + + modal_handler_calls: list[ModalSubmitEvent] = [] + + async def _modal_handler(event): + modal_handler_calls.append(event) + + chat.on_modal_submit("feedback_modal", _modal_handler) + tasks: list[Any] = [] + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": "Great!"}), + context_id, + WebhookOptions(wait_until=tasks.append), + ) + await asyncio.gather(*tasks) + + assert len(modal_handler_calls) == 1 + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-hook",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + body = json.loads(mock_fetch.await_args.kwargs["body"]) + assert body == { + "type": "modal_submit", + "callbackId": "feedback_modal", + "values": {"message": "Great!"}, + "user": {"id": "U123", "name": "user"}, + } + + # TS: "should not POST to modal callbackUrl when submit returns errors" + async def test_should_not_post_to_modal_callbackurl_when_submit_returns_errors(self): + chat, adapter, state = await _init_chat() + wait_until_calls: list[Any] = [] + + state.cache["modal-context:slack:ctx-errors"] = { + "callbackUrl": "https://example.com/webhook/modal-hook", + } + + async def _modal_handler(event): + return ModalResponse(action="errors", errors={"message": "Required"}) + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": ""}), + "ctx-errors", + WebhookOptions(wait_until=wait_until_calls.append), + ) + + assert response == ModalResponse(action="errors", errors={"message": "Required"}) + assert len(wait_until_calls) == 0 + assert mock_fetch.await_count == 0 + + # TS: "should not wait for modal callbackUrl before returning response" + async def test_should_not_wait_for_modal_callbackurl_before_returning_response(self): + chat, adapter, state = await _init_chat() + wait_until_calls: list[Any] = [] + fetch_started = asyncio.Event() + + async def _never_resolving_fetch(*args: Any, **kwargs: Any) -> Any: + fetch_started.set() + await asyncio.Event().wait() # never resolves + + state.cache["modal-context:slack:ctx-slow"] = { + "callbackUrl": "https://example.com/webhook/modal-hook", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(side_effect=_never_resolving_fetch)) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"message": "Great!"}), + "ctx-slow", + WebhookOptions(wait_until=wait_until_calls.append), + ) + + # The response came back while the POST is still in flight. + assert response == ModalResponse(action="clear") + assert len(wait_until_calls) == 1 + + await fetch_started.wait() + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-hook",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + # Clean up the in-flight task. + wait_until_calls[0].cancel() + await asyncio.gather(*wait_until_calls, return_exceptions=True) + + # TS: "should fire-and-forget modal callbackUrl when no waitUntil is provided" + async def test_should_fireandforget_modal_callbackurl_when_no_waituntil_is_provided(self): + chat, adapter, state = await _init_chat() + fetch_started = asyncio.Event() + release_fetch = asyncio.Event() + + async def _deferred_fetch(*args: Any, **kwargs: Any) -> tuple[int, str]: + fetch_started.set() + await release_fetch.wait() + return (200, "ok") + + state.cache["modal-context:slack:ctx-noWait"] = { + "callbackUrl": "https://example.com/webhook/modal-noWait", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(side_effect=_deferred_fetch)) as mock_fetch: + response = await chat.process_modal_submit( + _make_modal_submit_event(adapter, values={"msg": "ok"}), + "ctx-noWait", + ) + + assert response == ModalResponse(action="clear") + + await fetch_started.wait() + assert mock_fetch.await_count == 1 + assert mock_fetch.await_args.args == ("https://example.com/webhook/modal-noWait",) + assert mock_fetch.await_args.kwargs["method"] == "POST" + + release_fetch.set() + await asyncio.sleep(0) + + # TS: "should not POST when modal context has no callbackUrl" + async def test_should_not_post_when_modal_context_has_no_callbackurl(self): + chat, adapter, state = await _init_chat() + + state.cache["modal-context:slack:ctx-nocallback"] = {} + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(200, "ok"))) as mock_fetch: + await chat.process_modal_submit( + _make_modal_submit_event(adapter), + "ctx-nocallback", + ) + await asyncio.sleep(0.02) + + assert mock_fetch.await_count == 0 + + # TS: "should log error when modal callbackUrl POST returns non-2xx" + async def test_should_log_error_when_modal_callbackurl_post_returns_non2xx(self): + chat, adapter, state, logger = _make_callback_chat() + await chat.webhooks["slack"]("request") + tasks: list[Any] = [] + + state.cache["modal-context:slack:ctx-fail"] = { + "callbackUrl": "https://example.com/webhook/fail", + } + + async def _modal_handler(event): + return ModalResponse(action="clear") + + chat.on_modal_submit("feedback_modal", _modal_handler) + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(500, "nope"))): + await chat.process_modal_submit( + _make_modal_submit_event(adapter), + "ctx-fail", + WebhookOptions(wait_until=tasks.append), + ) + await asyncio.gather(*tasks) + + assert any(call_args[0] == "Modal callbackUrl POST failed" for call_args in logger.error.calls) + + +# ============================================================================ +# 26. Action callbackUrl error logging (vercel/chat#454) +# ============================================================================ + + +class TestActionCallbackUrlErrorLogging: + """TS describe("action callbackUrl error logging")""" + + # TS: "should log error when action callbackUrl POST returns non-2xx" + async def test_should_log_error_when_action_callbackurl_post_returns_non2xx(self): + chat, adapter, state, logger = _make_callback_chat() + await chat.webhooks["slack"]("request") + + async def _handler(event): + return None + + chat.on_action("approve", _handler) + + state.cache["chat:callback:bad-token"] = {"url": "https://example.com/webhook/will-fail"} + + event = _make_action_event(adapter, action_id="approve", value="__cb:bad-token") + + with patch("chat_sdk.callback_url._fetch", new=AsyncMock(return_value=(500, "nope"))): + await _process_action_and_wait(chat, event) + + assert any(call_args[0] == "Button callbackUrl POST failed" for call_args in logger.error.calls) + + +# ============================================================================ +# 25. Slash-command openModal without adapter support + subject wiring +# ============================================================================ + + +class TestSlashCommandOpenModalUnsupported: + """[Slash Commands] flavor of the openModal-unsupported test. + + Upstream chat.test.ts has two tests with this exact title (one under + the actions describe, one under slash commands); the fidelity matcher + counts Python names as a multiset, so each needs its own def. + """ + + # TS: "should return undefined from openModal when adapter does not support modals" + async def test_should_return_undefined_from_openmodal_when_adapter_does_not_support_modals( + self, + ): + adapter = create_mock_adapter("slack") + adapter.open_modal = None # type: ignore[assignment] + + chat, _, _ = await _init_chat(adapter=adapter) + captured_event: list[SlashCommandEvent] = [] + + async def _handler(event): + captured_event.append(event) + + chat.on_slash_command("/feedback", _handler) + + event = _make_slash_event(adapter, command="/feedback", text="", trigger_id="trigger-123") + chat.process_slash_command(event) + await asyncio.sleep(0.02) + + assert len(captured_event) == 1 + + modal = { + "type": "modal", + "callback_id": "test_modal", + "title": "Test Modal", + "children": [], + } + result = await captured_event[0].open_modal(modal) + assert result is None + + +class TestSubjectAdapterWiring: + # TS: "should wire adapter on message for subject access" + @requires_fetch_subject + async def test_should_wire_adapter_on_message_for_subject_access(self): + chat, adapter, _state = await _init_chat() + received: list[Any] = [] + + @chat.on_mention + async def handler(thread, message, context=None): + received.append(message) + + await chat.handle_incoming_message( + adapter, + "slack:C123:1234.5678", + create_test_message("msg-subject", "Hey @slack-bot test"), + ) + + assert len(received) == 1 + # The mock adapter exposes no fetch_subject hook -> resolves None + assert await received[0].subject is None diff --git a/tests/test_chat_resolver.py b/tests/test_chat_resolver.py index c89530eb..fdfe367f 100644 --- a/tests/test_chat_resolver.py +++ b/tests/test_chat_resolver.py @@ -481,45 +481,47 @@ def test_omitting_current_message_stubs_a_placeholder(self): def test_reuses_parent_chat_state_and_history(self): """The factory must bind the new Thread to the parent Chat's state - adapter and message history. This is the core contract of + adapter and thread history. This is the core contract of `chat.thread()` — worker processes reconstruct a Thread that shares state with the original Chat instance, not a fresh detached thread. Without this, state writes/reads from the worker wouldn't be visible to the Chat in-process. - For adapters that don't persist message history (persist_message_history + For adapters that don't persist thread history (persist_thread_history falsy), `_create_thread` intentionally skips the history bind — there's no cache to share. This test uses a persisting adapter to exercise the positive case. """ chat = _make_chat("slack") - # Opt the mock adapter into message history so the factory binds it + # Opt the mock adapter into thread history so the factory binds it adapter = chat._adapters["slack"] - adapter.persist_message_history = True + adapter.persist_thread_history = True thread = chat.thread("slack:C123:1234567890.123456") # State adapter must be the exact same instance, not a copy assert thread._state_adapter is chat._state_adapter - # Message history must be the exact same instance too - assert thread._message_history is chat._message_history + # Thread history must be the exact same instance too + assert thread._thread_history is chat._thread_history def test_omits_history_when_adapter_does_not_persist(self): - """Adapters with `persist_message_history` falsy opt out of the - shared history cache. `Chat.thread()` respects that: the Thread - gets `None` for history rather than a shared cache the adapter - won't populate. - - Explicitly set `persist_message_history = None` rather than - relying on the mock's default — prevents silent test regression - if the mock default ever changes. + """Adapters with `persist_thread_history` falsy (and the deprecated + `persist_message_history` alias also falsy) opt out of the shared + history cache. `Chat.thread()` respects that: the Thread gets + `None` for history rather than a shared cache the adapter won't + populate. + + Explicitly set both flags to `None` rather than relying on the + mock's defaults — prevents silent test regression if the mock + defaults ever change. """ chat = _make_chat("slack") adapter = chat._adapters["slack"] + adapter.persist_thread_history = None adapter.persist_message_history = None thread = chat.thread("slack:C123:1234567890.123456") assert thread._state_adapter is chat._state_adapter - assert thread._message_history is None + assert thread._thread_history is None def test_invalid_thread_id_raises(self): from chat_sdk.errors import ChatError diff --git a/tests/test_discord_adapter.py b/tests/test_discord_adapter.py index f85cfb9c..9e15fb39 100644 --- a/tests/test_discord_adapter.py +++ b/tests/test_discord_adapter.py @@ -406,7 +406,9 @@ async def test_dispatches_slash_command_to_chat(self): mock_chat.process_slash_command.assert_called_once() call_args = mock_chat.process_slash_command.call_args[0][0] assert call_args.command == "/test" - assert call_args.text == "status True" + # Boolean option values flatten as JSON-style "true"/"false", + # matching TS `String(true)` (wire parity, vercel/chat#490 test). + assert call_args.text == "status true" @pytest.mark.asyncio async def test_expands_subcommand_path(self): diff --git a/tests/test_discord_cards.py b/tests/test_discord_cards.py index ea99ba59..c3b1bce1 100644 --- a/tests/test_discord_cards.py +++ b/tests/test_discord_cards.py @@ -5,6 +5,8 @@ from __future__ import annotations +import pytest + from chat_sdk.adapters.discord.cards import ( BUTTON_STYLE_DANGER, BUTTON_STYLE_LINK, @@ -13,6 +15,8 @@ DISCORD_BLURPLE, card_to_discord_payload, card_to_fallback_text, + decode_discord_custom_id, + encode_discord_custom_id, ) from chat_sdk.cards import ( Actions, @@ -27,6 +31,7 @@ LinkButton, Section, ) +from chat_sdk.shared.errors import ValidationError # --------------------------------------------------------------------------- # cardToDiscordPayload @@ -113,7 +118,8 @@ def test_actions_with_buttons(self): assert buttons[1]["type"] == 2 assert buttons[1]["style"] == BUTTON_STYLE_DANGER assert buttons[1]["label"] == "Reject" - assert buttons[1]["custom_id"] == "reject" + # Button values are packed into custom_id (vercel/chat#454) + assert buttons[1]["custom_id"] == "reject\ndata-123" assert buttons[2]["type"] == 2 assert buttons[2]["style"] == BUTTON_STYLE_SECONDARY @@ -272,3 +278,124 @@ def test_empty_card(self): card = Card() text = card_to_fallback_text(card) assert text == "" + + +# --------------------------------------------------------------------------- +# encodeDiscordCustomId / decodeDiscordCustomId (vercel/chat#454) +# --------------------------------------------------------------------------- + + +class TestEncodeDecodeDiscordCustomId: + """describe("encodeDiscordCustomId / decodeDiscordCustomId")""" + + # it("encodes actionId only when no value") + def test_encodes_actionid_only_when_no_value(self): + assert encode_discord_custom_id("approve") == "approve" + + # it("encodes actionId with value") + def test_encodes_actionid_with_value(self): + assert encode_discord_custom_id("approve", "order-123") == "approve\norder-123" + + # it("skips encoding when empty value") + def test_skips_encoding_when_empty_value(self): + assert encode_discord_custom_id("approve", "") == "approve" + + # it("throws when actionId is empty") + def test_throws_when_actionid_is_empty(self): + with pytest.raises(ValidationError): + encode_discord_custom_id("") + + # it("throws when actionId exceeds 100 chars") + def test_throws_when_actionid_exceeds_100_chars(self): + with pytest.raises(ValidationError): + encode_discord_custom_id("x" * 101) + + # it("throws when encoded custom_id exceeds 100 chars") + def test_throws_when_encoded_custom_id_exceeds_100_chars(self): + long_value = "x" * 100 + with pytest.raises(ValidationError): + encode_discord_custom_id("btn", long_value) + + # it("throws when a button value makes custom_id too long") + def test_throws_when_a_button_value_makes_custom_id_too_long(self): + card = Card( + children=[ + Actions( + [ + Button( + id="x" * 90, + label="Approve", + value="__cb:1234567890abcdef", + ) + ] + ), + ] + ) + + with pytest.raises(ValidationError): + card_to_discord_payload(card) + + # it("decodes actionId only") + def test_decodes_actionid_only(self): + decoded = decode_discord_custom_id("approve") + assert decoded.action_id == "approve" + assert decoded.value is None + + # it("decodes actionId with value") + def test_decodes_actionid_with_value(self): + decoded = decode_discord_custom_id("approve\norder-123") + assert decoded.action_id == "approve" + assert decoded.value == "order-123" + + # it("round-trips encode/decode") + def test_roundtrips_encodedecode(self): + encoded = encode_discord_custom_id("btn", "__cb:a1b2c3d4e5f6g7h8") + decoded = decode_discord_custom_id(encoded) + assert decoded.action_id == "btn" + assert decoded.value == "__cb:a1b2c3d4e5f6g7h8" + + # it("preserves embedded delimiter chars in the value (decoder splits on first only)") + def test_preserves_embedded_delimiter_chars_in_the_value_decoder_splits_on_first_only(self): + decoded = decode_discord_custom_id("btn\nfirst\nsecond") + assert decoded.action_id == "btn" + assert decoded.value == "first\nsecond" + + # it("treats explicitly null value as no value") + def test_treats_explicitly_null_value_as_no_value(self): + assert encode_discord_custom_id("approve", None) == "approve" + + # it("encodes a custom_id at the 100 char boundary") + def test_encodes_a_custom_id_at_the_100_char_boundary(self): + action_id = "a" * 50 + value = "b" * 49 + encoded = encode_discord_custom_id(action_id, value) + assert len(encoded) == 100 + decoded = decode_discord_custom_id(encoded) + assert decoded.action_id == action_id + assert decoded.value == value + + # it("rejects a custom_id one char past the boundary") + def test_rejects_a_custom_id_one_char_past_the_boundary(self): + action_id = "a" * 50 + value = "b" * 50 + with pytest.raises(ValidationError): + encode_discord_custom_id(action_id, value) + + # it("renders cards with values into Discord button payloads") + def test_renders_cards_with_values_into_discord_button_payloads(self): + card = Card( + children=[ + Actions( + [ + Button(id="approve", label="Approve", value="order-99"), + Button(id="deny", label="Deny"), + ] + ), + ] + ) + + payload = card_to_discord_payload(card) + buttons = payload["components"][0]["components"] + + assert buttons[0]["custom_id"] == "approve\norder-99" + assert buttons[1]["custom_id"] == "deny" diff --git a/tests/test_discord_extended.py b/tests/test_discord_extended.py index 33aacf3c..9189e738 100644 --- a/tests/test_discord_extended.py +++ b/tests/test_discord_extended.py @@ -923,6 +923,218 @@ async def test_handles_reaction_remove(self): assert call_args.message_id == "msg123" +# ============================================================================ +# Forwarded gateway interactions (gateway-only mode, vercel/chat#490) +# ============================================================================ + + +class TestForwardedGatewayInteractions: + """Port of the "legacy gateway interactions" describe block. + + Discord sends interactions through either the Gateway or an + Interactions Endpoint URL, not both. In gateway-only deployments the + forwarder relays the raw INTERACTION_CREATE payload; the adapter must + defer it via the interaction callback REST endpoint (the wire call + discord.js makes for deferReply/deferUpdate) and route through the + existing slash-command / action handler paths. + """ + + def _slash_interaction(self, **overrides): + interaction = { + "id": "interaction123", + "application_id": "test-app-id", + "token": "interaction-token", + "type": 2, # APPLICATION_COMMAND + "version": 1, + "guild_id": "guild123", + "channel_id": "channel456", + "channel": {"id": "channel456", "type": 0}, + "user": { + "id": "user789", + "username": "testuser", + "discriminator": "0001", + "global_name": "Test User", + "bot": False, + }, + "data": { + "name": "test", + "type": 1, + "options": [ + {"name": "topic", "type": 3, "value": "status"}, + {"name": "verbose", "type": 5, "value": True}, + ], + }, + } + interaction.update(overrides) + return interaction + + def _component_interaction(self, **overrides): + interaction = { + "id": "interaction123", + "application_id": "test-app-id", + "token": "interaction-token", + "type": 3, # MESSAGE_COMPONENT + "version": 1, + "guild_id": "guild123", + "channel_id": "channel456", + "channel": {"id": "channel456", "type": 0}, + "user": { + "id": "user789", + "username": "testuser", + "discriminator": "0001", + "global_name": "Test User", + "bot": False, + }, + "data": {"custom_id": "approve_btn", "component_type": 2}, + "message": {"id": "message123"}, + } + interaction.update(overrides) + return interaction + + def _forwarded(self, interaction) -> str: + return json.dumps( + { + "type": "GATEWAY_INTERACTION_CREATE", + "timestamp": 1234567890, + "data": interaction, + } + ) + + @pytest.mark.asyncio + async def test_handles_slash_command_interactions_from_the_gateway(self): + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + + assert response["status"] == 200 + # deferReply: explicit callback REST call (type 5) + adapter._discord_fetch.assert_awaited_once_with( + "/interactions/interaction123/interaction-token/callback", + "POST", + {"type": 5}, + ) + mock_chat.process_slash_command.assert_called_once() + event = mock_chat.process_slash_command.call_args[0][0] + assert event.command == "/test" + assert event.text == "status true" + assert event.channel_id == "discord:guild123:channel456" + assert event.user.user_id == "user789" + assert event.user.user_name == "testuser" + assert event.user.full_name == "Test User" + + @pytest.mark.asyncio + async def test_handles_component_interactions_from_the_gateway(self): + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._component_interaction()))) + + assert response["status"] == 200 + # deferUpdate: explicit callback REST call (type 6) + adapter._discord_fetch.assert_awaited_once_with( + "/interactions/interaction123/interaction-token/callback", + "POST", + {"type": 6}, + ) + mock_chat.process_action.assert_called_once() + event = mock_chat.process_action.call_args[0][0] + assert event.action_id == "approve_btn" + assert event.value == "approve_btn" + assert event.message_id == "message123" + assert event.thread_id == "discord:guild123:channel456" + + @pytest.mark.asyncio + async def test_slash_command_defer_failure_skips_handler(self): + """If the deferral REST call fails the handler must not run -- + matches upstream where a deferReply rejection is caught and logged + before the normalize+handle step.""" + logger = _make_logger() + adapter = _make_adapter(logger=logger) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(side_effect=NetworkError("discord", "boom")) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + + # Forwarded-event responses stay 200; the failure is logged. + assert response["status"] == 200 + mock_chat.process_slash_command.assert_not_called() + error_messages = [c.args[0] for c in logger.error.call_args_list] + assert "Error handling Gateway interaction" in error_messages + + @pytest.mark.asyncio + async def test_unhandled_interaction_type_is_ignored_without_defer(self): + """PING/autocomplete-style interactions are not deferred or routed.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_slash_command = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + response = await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction(type=4)))) + + assert response["status"] == 200 + adapter._discord_fetch.assert_not_awaited() + mock_chat.process_slash_command.assert_not_called() + mock_chat.process_action.assert_not_called() + + @pytest.mark.asyncio + async def test_interaction_missing_token_is_not_deferred_or_routed(self): + """A malformed forward without id/token must not produce a garbage + callback URL or dispatch a handler.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + mock_chat.process_action = MagicMock() + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value=None) + + interaction = self._component_interaction() + del interaction["token"] + response = await adapter.handle_webhook(_gateway_request(self._forwarded(interaction))) + + assert response["status"] == 200 + adapter._discord_fetch.assert_not_awaited() + mock_chat.process_action.assert_not_called() + + @pytest.mark.asyncio + async def test_gateway_slash_command_supports_deferred_response_flow(self): + """The gateway-deferred slash interaction resolves like an HTTP one: + the handler's first post_message PATCHes the @original webhook + message using the interaction token.""" + adapter = _make_adapter(logger=_make_logger()) + mock_chat = MagicMock() + + def run_handler(event, options=None): + # Simulate Chat dispatching a handler that replies immediately. + import asyncio as _asyncio + + task = _asyncio.get_running_loop().create_task(adapter.post_message(event.channel_id, "reply from handler")) + run_handler.task = task + + mock_chat.process_slash_command = MagicMock(side_effect=run_handler) + adapter._chat = mock_chat + adapter._discord_fetch = AsyncMock(return_value={"id": "reply-msg-1"}) + + await adapter.handle_webhook(_gateway_request(self._forwarded(self._slash_interaction()))) + await run_handler.task + + paths = [c.args[0] for c in adapter._discord_fetch.await_args_list] + # First the deferral, then the @original PATCH (not a channel POST). + assert paths[0] == "/interactions/interaction123/interaction-token/callback" + assert paths[1] == "/webhooks/test-app-id/interaction-token/messages/@original" + patch_call = adapter._discord_fetch.await_args_list[1] + assert patch_call.args[1] == "PATCH" + + # ============================================================================ # Forwarded message -- thread detection # ============================================================================ diff --git a/tests/test_messenger_api.py b/tests/test_messenger_api.py new file mode 100644 index 00000000..97e7c31c --- /dev/null +++ b/tests/test_messenger_api.py @@ -0,0 +1,989 @@ +"""Tests for the Messenger adapter — Graph API send & stream paths. + +Covers ``post_message`` (text / markdown / AST / generic-template card / +button-template card / text fallback), ``stream`` accumulation, +``start_typing``, ``edit_message`` / ``delete_message`` / +``add_reaction`` / ``remove_reaction`` unsupported paths, message +truncation, message caching after send, and Graph API error mapping +(rate limit / auth / not-found / network). + +Stubs the adapter's private ``_graph_api_fetch`` helper so we never hit +the network, mirroring the WhatsApp ``test_whatsapp_api.py`` pattern. + +Pairs with ``tests/test_messenger_webhook.py``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.adapters.messenger.adapter import ( + MESSENGER_MESSAGE_LIMIT, + MessengerAdapter, +) +from chat_sdk.adapters.messenger.types import MessengerAdapterConfig, MessengerThreadId +from chat_sdk.logger import ConsoleLogger +from chat_sdk.shared.errors import ( + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError, +) +from chat_sdk.types import MarkdownTextChunk, StreamChunk + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +THREAD_ID = "messenger:USER_123" +RECIPIENT_ID = "USER_123" + + +def _make_adapter(**overrides: Any) -> MessengerAdapter: + defaults: dict[str, Any] = { + "app_secret": "test-app-secret", + "page_access_token": "test-page-token", + "verify_token": "test-verify-token", + "user_name": "test-bot", + "logger": ConsoleLogger("error"), + } + defaults.update(overrides) + return MessengerAdapter(MessengerAdapterConfig(**defaults)) + + +def _send_api_response(message_id: str = "mid.sent") -> dict[str, Any]: + return {"recipient_id": RECIPIENT_ID, "message_id": message_id} + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +class TestInitialize: + """``initialize`` fetches Page identity, but failure is non-fatal.""" + + @pytest.mark.asyncio + async def test_initialize_sets_bot_id_and_name(self) -> None: + adapter = _make_adapter() + # Drop explicit user_name so /me name is used. + adapter._has_explicit_user_name = False + adapter._user_name = "bot" + adapter._graph_api_fetch = AsyncMock(return_value={"id": "PAGE_456", "name": "My Cool Page"}) + + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + await adapter.initialize(chat) + + assert adapter.bot_user_id == "PAGE_456" + assert adapter.user_name == "My Cool Page" + + @pytest.mark.asyncio + async def test_initialize_preserves_explicit_user_name(self) -> None: + adapter = _make_adapter(user_name="CustomBot") + adapter._graph_api_fetch = AsyncMock(return_value={"id": "PAGE_456", "name": "Page Name"}) + + chat = MagicMock() + chat.get_user_name.return_value = "ChatName" + await adapter.initialize(chat) + + # Explicit user_name wins over both chat.get_user_name() and /me name. + assert adapter.user_name == "CustomBot" + + @pytest.mark.asyncio + async def test_initialize_uses_chat_user_name_when_no_explicit(self) -> None: + adapter = _make_adapter() + adapter._has_explicit_user_name = False + adapter._user_name = "bot" + # /me fails — falls back to chat.get_user_name(). + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("API down")) + + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + await adapter.initialize(chat) + + assert adapter.bot_user_id is None + assert adapter.user_name == "TestBot" + + @pytest.mark.asyncio + async def test_initialize_continues_when_me_fails(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("API down")) + chat = MagicMock() + chat.get_user_name.return_value = "Bot" + # Should not raise. + await adapter.initialize(chat) + assert adapter.bot_user_id is None + + +# --------------------------------------------------------------------------- +# post_message — text +# --------------------------------------------------------------------------- + + +class TestPostMessageText: + """``post_message`` sends a single text payload via the Send API.""" + + @pytest.mark.asyncio + async def test_post_plain_string(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.s")) + result = await adapter.post_message(THREAD_ID, "Hello!") + + assert result.id == "mid.s" + assert result.thread_id == THREAD_ID + endpoint, kwargs = adapter._graph_api_fetch.call_args.args, adapter._graph_api_fetch.call_args.kwargs + assert endpoint[0] == "me/messages" + assert kwargs["method"] == "POST" + body = kwargs["body"] + assert body["recipient"] == {"id": RECIPIENT_ID} + assert body["message"]["text"] == "Hello!" + assert body["messaging_type"] == "RESPONSE" + + @pytest.mark.asyncio + async def test_post_markdown(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.md")) + await adapter.post_message(THREAD_ID, {"markdown": "**bold** and *italic*"}) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + # Messenger doesn't render markdown — text contains the source. + assert "bold" in body["message"]["text"] + assert "italic" in body["message"]["text"] + + @pytest.mark.asyncio + async def test_post_ast(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.ast")) + await adapter.post_message( + THREAD_ID, + { + "ast": { + "type": "root", + "children": [ + {"type": "paragraph", "children": [{"type": "text", "value": "ast content"}]}, + ], + } + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert "ast content" in body["message"]["text"] + + @pytest.mark.asyncio + async def test_rejects_empty_message(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + with pytest.raises(ValidationError): + await adapter.post_message(THREAD_ID, " ") + adapter._graph_api_fetch.assert_not_called() + + @pytest.mark.asyncio + async def test_exact_2000_chars_unchanged(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + text = "x" * MESSENGER_MESSAGE_LIMIT + await adapter.post_message(THREAD_ID, text) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == text + assert len(body["message"]["text"]) == MESSENGER_MESSAGE_LIMIT + + @pytest.mark.asyncio + async def test_2001_chars_truncated_with_ellipsis(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + text = "y" * (MESSENGER_MESSAGE_LIMIT + 1) + await adapter.post_message(THREAD_ID, text) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert len(body["message"]["text"]) == MESSENGER_MESSAGE_LIMIT + assert body["message"]["text"].endswith("...") + + @pytest.mark.asyncio + async def test_long_text_truncated(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + await adapter.post_message(THREAD_ID, "a" * 3000) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert len(body["message"]["text"]) <= MESSENGER_MESSAGE_LIMIT + assert body["message"]["text"].endswith("...") + + @pytest.mark.asyncio + async def test_sent_message_is_cached(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.cached")) + await adapter.post_message(THREAD_ID, "cached msg") + fetched = await adapter.fetch_message(THREAD_ID, "mid.cached") + assert fetched is not None + assert "cached msg" in fetched.text + + @pytest.mark.asyncio + async def test_resolves_bare_psid(self) -> None: + """A non-prefixed value is treated as a raw PSID.""" + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.raw")) + result = await adapter.post_message("USER_123", "hi") + assert result.id == "mid.raw" + + +# --------------------------------------------------------------------------- +# post_message — card templates +# --------------------------------------------------------------------------- + + +class TestPostMessageCard: + """``post_message`` routes cards to generic / button templates or text.""" + + @pytest.mark.asyncio + async def test_generic_template_for_card_with_title(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.tmpl")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "title": "Welcome", + "children": [ + {"type": "text", "content": "Hello!"}, + { + "type": "actions", + "children": [ + {"type": "button", "id": "start", "label": "Start"}, + {"type": "button", "id": "help", "label": "Help"}, + ], + }, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + attachment = body["message"]["attachment"] + assert attachment["type"] == "template" + assert attachment["payload"]["template_type"] == "generic" + assert len(attachment["payload"]["elements"]) == 1 + assert attachment["payload"]["elements"][0]["title"] == "Welcome" + assert len(attachment["payload"]["elements"][0]["buttons"]) == 2 + + @pytest.mark.asyncio + async def test_button_template_for_titleless_card(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.btn")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "children": [ + {"type": "text", "content": "Please choose:"}, + { + "type": "actions", + "children": [{"type": "button", "id": "opt1", "label": "Option 1"}], + }, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + payload = body["message"]["attachment"]["payload"] + assert payload["template_type"] == "button" + assert payload["text"] == "Please choose:" + + @pytest.mark.asyncio + async def test_text_fallback_for_unsupported_card(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.txt")) + await adapter.post_message( + THREAD_ID, + { + "type": "card", + "title": "With Table", + "children": [ + {"type": "table", "headers": ["A", "B"], "rows": [["1", "2"]]}, + ], + }, + ) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + # Falls back to text — no attachment field. + assert "attachment" not in body["message"] + assert "With Table" in body["message"]["text"] + + +# --------------------------------------------------------------------------- +# stream +# --------------------------------------------------------------------------- + + +class TestStream: + """``stream`` buffers chunks and posts once.""" + + @pytest.mark.asyncio + async def test_buffers_str_chunks(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response("mid.s")) + + async def _chunks() -> AsyncIterator[str | StreamChunk]: + yield "Hello" + yield " " + yield "world" + + result = await adapter.stream(THREAD_ID, _chunks()) + assert result.id == "mid.s" + assert adapter._graph_api_fetch.call_count == 1 + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == "Hello world" + + @pytest.mark.asyncio + async def test_buffers_markdown_text_chunks(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=_send_api_response()) + + async def _chunks() -> AsyncIterator[str | StreamChunk]: + yield MarkdownTextChunk(text="Structured ") + yield "plain " + yield MarkdownTextChunk(text="content") + + await adapter.stream(THREAD_ID, _chunks()) + body = adapter._graph_api_fetch.call_args.kwargs["body"] + assert body["message"]["text"] == "Structured plain content" + + +# --------------------------------------------------------------------------- +# Typing indicator +# --------------------------------------------------------------------------- + + +class TestStartTyping: + @pytest.mark.asyncio + async def test_sends_typing_on(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={}) + await adapter.start_typing(THREAD_ID) + kwargs = adapter._graph_api_fetch.call_args.kwargs + assert kwargs["body"]["sender_action"] == "typing_on" + assert kwargs["body"]["recipient"]["id"] == RECIPIENT_ID + + +# --------------------------------------------------------------------------- +# Unsupported operations +# --------------------------------------------------------------------------- + + +class TestUnsupportedOperations: + """Operations that Messenger doesn't support — must raise ValidationError.""" + + @pytest.mark.asyncio + async def test_edit_message_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.edit_message(THREAD_ID, "mid.1", "new text") + + @pytest.mark.asyncio + async def test_delete_message_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.delete_message(THREAD_ID, "mid.1") + + @pytest.mark.asyncio + async def test_add_reaction_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.add_reaction(THREAD_ID, "mid.1", "thumbsup") + + @pytest.mark.asyncio + async def test_remove_reaction_raises(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + await adapter.remove_reaction(THREAD_ID, "mid.1", "thumbsup") + + +# --------------------------------------------------------------------------- +# Thread / channel info +# --------------------------------------------------------------------------- + + +class TestFetchThreadAndChannel: + """``fetch_thread`` / ``fetch_channel_info`` use the Graph user profile.""" + + @pytest.mark.asyncio + async def test_thread_with_full_name(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "John", "last_name": "Doe"}) + thread = await adapter.fetch_thread(THREAD_ID) + assert thread.channel_name == "John Doe" + assert thread.is_dm is True + + @pytest.mark.asyncio + async def test_channel_info_with_full_name(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock( + return_value={"id": "USER_123", "first_name": "Jane", "last_name": "Smith"} + ) + info = await adapter.fetch_channel_info(RECIPIENT_ID) + assert info.name == "Jane Smith" + assert info.is_dm is True + + @pytest.mark.asyncio + async def test_falls_back_to_user_id_on_profile_error(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(side_effect=RuntimeError("err")) + info = await adapter.fetch_channel_info(RECIPIENT_ID) + assert info.name == RECIPIENT_ID + + @pytest.mark.asyncio + async def test_first_name_only(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "Alice"}) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == "Alice" + + @pytest.mark.asyncio + async def test_last_name_only(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "last_name": "Smith"}) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == "Smith" + + @pytest.mark.asyncio + async def test_caches_user_profile(self) -> None: + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value={"id": "USER_123", "first_name": "John"}) + await adapter.fetch_thread(THREAD_ID) + await adapter.fetch_thread(THREAD_ID) + # Second call hits the cache — no extra API call. + assert adapter._graph_api_fetch.call_count == 1 + + @pytest.mark.asyncio + async def test_empty_dict_cache_entry_is_a_hit_not_a_miss(self) -> None: + """Regression: a cached ``{}`` profile must NOT trigger a re-fetch. + + The old ``if cached:`` treated ``{}`` (falsy) as a miss and called the + Graph API every time. The fix ``if cached is not None`` honors the + cache entry regardless of dict contents. Without the fix this test + would observe ``call_count == 1`` (the re-fetch). + """ + adapter = _make_adapter() + adapter._user_profile_cache[RECIPIENT_ID] = {} # pre-populated empty hit + adapter._graph_api_fetch = AsyncMock(return_value={"id": RECIPIENT_ID, "first_name": "Should-Not-Be-Called"}) + + profile = await adapter._fetch_user_profile(RECIPIENT_ID) + + # Cache hit returns the empty dict as-is, no Graph API roundtrip. + assert profile == {} + assert adapter._graph_api_fetch.call_count == 0 + + @pytest.mark.asyncio + async def test_non_dict_profile_response_falls_back(self) -> None: + """A successful Graph API call that returns a non-mapping must not poison the cache. + + ``_graph_api_fetch`` is typed loosely and could in principle yield + ``None`` or a list (e.g. an API shape change, a stubbed test, a + proxy returning an array). Without the ``isinstance(profile, dict)`` + guard, ``_profile_display_name`` then calls ``.get`` on whatever + came back and raises ``AttributeError``, and the next call hits the + same poisoned cache entry. Verify (a) the minimal ``{"id": uid}`` + fallback is returned, and (b) the cache stays empty. + """ + adapter = _make_adapter() + adapter._graph_api_fetch = AsyncMock(return_value=None) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == RECIPIENT_ID + assert RECIPIENT_ID not in adapter._user_profile_cache + + # Same again for a list, to pin the dict-only contract. + adapter._graph_api_fetch = AsyncMock(return_value=[{"id": RECIPIENT_ID}]) + info = await adapter.fetch_thread(THREAD_ID) + assert info.channel_name == RECIPIENT_ID + assert RECIPIENT_ID not in adapter._user_profile_cache + + +# --------------------------------------------------------------------------- +# Message fetching / pagination +# --------------------------------------------------------------------------- + + +def _seed_messages(adapter: MessengerAdapter, count: int) -> None: + for i in range(1, count + 1): + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000 + i * 1000, + "message": {"mid": f"mid.{i}", "text": f"message {i}"}, + } + ) + + +class TestFetchMessages: + """Local message cache backs ``fetch_messages`` / ``fetch_message``.""" + + @pytest.mark.asyncio + async def test_empty_unknown_thread(self) -> None: + adapter = _make_adapter() + result = await adapter.fetch_messages("messenger:UNKNOWN") + assert result.messages == [] + + @pytest.mark.asyncio + async def test_backward_default(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=3)) + assert [m.id for m in result.messages] == ["mid.3", "mid.4", "mid.5"] + assert result.next_cursor == "mid.3" + + @pytest.mark.asyncio + async def test_backward_with_cursor(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, cursor="mid.3", direction="backward"), + ) + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + + @pytest.mark.asyncio + async def test_forward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, direction="forward"), + ) + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + assert result.next_cursor == "mid.2" + + @pytest.mark.asyncio + async def test_forward_with_cursor(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(limit=2, cursor="mid.2", direction="forward"), + ) + assert [m.id for m in result.messages] == ["mid.3", "mid.4"] + + @pytest.mark.asyncio + async def test_no_next_cursor_when_exhausted(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=100)) + assert len(result.messages) == 5 + assert result.next_cursor is None + + @pytest.mark.asyncio + async def test_clamps_negative_limit(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=-10)) + assert len(result.messages) == 1 + + @pytest.mark.asyncio + async def test_clamps_high_limit(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=500)) + assert len(result.messages) == 5 + + @pytest.mark.asyncio + async def test_explicit_zero_limit_is_not_swallowed_to_default(self) -> None: + """Regression: ``FetchOptions(limit=0)`` must not silently become 50. + + The old ``limit = options.limit or 50`` treated ``0`` as falsy and + substituted the default page size — a caller asking for zero messages + got fifty. Switching to ``is not None`` preserves the explicit value + (then ``max(1, ...)`` clamps to 1, matching ``limit=-N`` behavior). + Without the fix, with 5 messages seeded, this test would observe + ``len(result.messages) == 5`` (all of them, since 50 > 5). + """ + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 5) + result = await adapter.fetch_messages(THREAD_ID, FetchOptions(limit=0)) + # New behavior: clamped to 1 via ``max(1, min(0, 100))``, NOT 50. + assert len(result.messages) == 1 + + @pytest.mark.asyncio + async def test_unknown_cursor_backward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 3) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(cursor="mid.nonexistent", direction="backward", limit=2), + ) + # Falls back to "from end". + assert [m.id for m in result.messages] == ["mid.2", "mid.3"] + + @pytest.mark.asyncio + async def test_unknown_cursor_forward(self) -> None: + from chat_sdk.types import FetchOptions + + adapter = _make_adapter() + _seed_messages(adapter, 3) + result = await adapter.fetch_messages( + THREAD_ID, + FetchOptions(cursor="mid.nonexistent", direction="forward", limit=2), + ) + # Falls back to "from start". + assert [m.id for m in result.messages] == ["mid.1", "mid.2"] + + @pytest.mark.asyncio + async def test_fetch_single_unknown_message_returns_none(self) -> None: + adapter = _make_adapter() + result = await adapter.fetch_message(THREAD_ID, "mid.nope") + assert result is None + + @pytest.mark.asyncio + async def test_sort_by_timestamp_then_sequence(self) -> None: + """Equal timestamps order by the ``:N`` suffix on the mid.""" + adapter = _make_adapter() + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc:2", "text": "second"}, + } + ) + adapter.parse_message( + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc:1", "text": "first"}, + } + ) + result = await adapter.fetch_messages(THREAD_ID) + assert result.messages[0].text == "first" + assert result.messages[1].text == "second" + + @pytest.mark.asyncio + async def test_reparsing_same_id_updates_cache(self) -> None: + adapter = _make_adapter() + event1 = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.dup", "text": "first"}, + } + event2 = dict(event1) + event2["message"] = {"mid": "mid.dup", "text": "updated"} + adapter.parse_message(event1) + updated = adapter.parse_message(event2) + assert updated.text == "updated" + + +# --------------------------------------------------------------------------- +# parseMessage +# --------------------------------------------------------------------------- + + +class TestParseMessage: + """``parse_message`` produces a normalized ``Message`` from raw events.""" + + def test_basic_message(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc123", "text": "hello"}, + } + parsed = adapter.parse_message(event) + assert parsed.text == "hello" + assert parsed.thread_id == "messenger:USER_123" + assert parsed.id == "mid.abc123" + + def test_is_mention_true(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.x", "text": "hi"}, + } + parsed = adapter.parse_message(event) + # All inbound Messenger messages are 1:1 DMs — always a mention. + assert parsed.is_mention is True + + def test_echo_marks_as_me_and_bot(self) -> None: + adapter = _make_adapter() + adapter._bot_user_id = "PAGE_456" + event = { + "sender": {"id": "PAGE_456"}, + "recipient": {"id": "USER_123"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.echo", "text": "bot says", "is_echo": True}, + } + parsed = adapter.parse_message(event) + assert parsed.author.is_me is True + assert parsed.author.is_bot is True + + def test_echo_threads_by_recipient_psid(self) -> None: + """Echo events flip sender/recipient: ``sender.id`` is the Page ID and + ``recipient.id`` is the user's PSID. ``parse_message`` must thread the + echo off the user PSID so it lands in the same conversation as the + inbound user messages (matching ``_handle_echo``), not the page id. + """ + adapter = _make_adapter() + adapter._bot_user_id = "PAGE_456" + event = { + "sender": {"id": "PAGE_456"}, + "recipient": {"id": "USER_123"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.echo", "text": "bot says", "is_echo": True}, + } + parsed = adapter.parse_message(event) + # Threaded under the user's PSID, NOT the page id. + assert parsed.thread_id == "messenger:USER_123" + assert parsed.thread_id != "messenger:PAGE_456" + # Must match what _handle_echo derives for the same event. + expected = adapter.encode_thread_id(MessengerThreadId(recipient_id="USER_123")) + assert parsed.thread_id == expected + + def test_non_echo_threads_by_sender_psid(self) -> None: + """Non-echo inbound messages have ``sender.id`` == user PSID and must + keep threading off the sender — guards against over-applying the echo + fix to normal inbound events. + """ + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc", "text": "hi"}, + } + parsed = adapter.parse_message(event) + assert parsed.thread_id == "messenger:USER_123" + + def test_postback_uses_title_as_text(self) -> None: + adapter = _make_adapter() + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Get Started", "payload": "START"}, + } + parsed = adapter.parse_message(event) + assert parsed.id == "event:1735689600000" + assert parsed.text == "Get Started" + + def test_render_formatted_returns_text(self) -> None: + adapter = _make_adapter() + out = adapter.render_formatted( + { + "type": "root", + "children": [ + {"type": "paragraph", "children": [{"type": "text", "value": "hello world"}]}, + ], + } + ) + assert "hello world" in out + + +# --------------------------------------------------------------------------- +# Graph API error mapping +# --------------------------------------------------------------------------- + + +class _FakeResponse: + """Async-context-manager response stub for aiohttp-compatible callers.""" + + def __init__(self, status: int, json_data: Any) -> None: + self.status = status + self._json_data = json_data + + async def json(self, content_type: Any = None) -> Any: + return self._json_data + + async def __aenter__(self) -> _FakeResponse: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + +class _FakeSession: + """Minimal aiohttp-like session stub. Records the last GET/POST call.""" + + def __init__(self, response: _FakeResponse | Exception) -> None: + self._response = response + self.closed = False + self.last_method: str | None = None + + def get(self, url: str, **kwargs: Any) -> Any: + self.last_method = "GET" + if isinstance(self._response, Exception): + raise self._response + return self._response + + def post(self, url: str, **kwargs: Any) -> Any: + self.last_method = "POST" + if isinstance(self._response, Exception): + raise self._response + return self._response + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def _call_with_response(status: int, json_data: Any) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(status, json_data)) + await adapter.start_typing(THREAD_ID) + + +class TestGraphApiErrors: + """``_graph_api_fetch`` maps Meta error codes to typed adapter errors.""" + + @pytest.mark.asyncio + async def test_rate_limit_on_429(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(429, {"error": {"message": "Rate limited"}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_4(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Too many calls", "code": 4}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_32(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Page rate limit", "code": 32}})) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_rate_limit_on_code_613(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(400, {"error": {"message": "Custom rate limit", "code": 613}}) + ) + with pytest.raises(AdapterRateLimitError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_auth_error_on_401(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(401, {"error": {"message": "Invalid token", "code": 190}})) + with pytest.raises(AuthenticationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_auth_error_on_code_190(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(400, {"error": {"message": "Token expired", "code": 190}})) + with pytest.raises(AuthenticationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_validation_on_403(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(403, {"error": {"message": "Permission denied", "code": 10}}) + ) + with pytest.raises(ValidationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_validation_on_code_200(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession( + _FakeResponse(400, {"error": {"message": "Requires permission", "code": 200}}) + ) + with pytest.raises(ValidationError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_not_found_on_404(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(404, {"error": {"message": "Not found"}})) + with pytest.raises(ResourceNotFoundError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_generic_5xx(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"message": "Internal error", "code": 2}})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_session_exception(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(RuntimeError("DNS failure")) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_network_on_unparseable_response(self) -> None: + class _BadResp(_FakeResponse): + async def json(self, content_type: Any = None) -> Any: + raise ValueError("not json") + + adapter = _make_adapter() + adapter._http_session = _FakeSession(_BadResp(200, None)) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_fallback_message_when_no_error_message(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"code": 999}})) + with pytest.raises(NetworkError, match="Messenger API"): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_uses_status_as_code_when_missing(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {"error": {"message": "Something failed"}})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + @pytest.mark.asyncio + async def test_no_error_object(self) -> None: + adapter = _make_adapter() + adapter._http_session = _FakeSession(_FakeResponse(500, {})) + with pytest.raises(NetworkError): + await adapter.start_typing(THREAD_ID) + + +# --------------------------------------------------------------------------- +# Disconnect +# --------------------------------------------------------------------------- + + +class TestDisconnect: + @pytest.mark.asyncio + async def test_disconnect_closes_session(self) -> None: + adapter = _make_adapter() + session = _FakeSession(_FakeResponse(200, {})) + adapter._http_session = session + await adapter.disconnect() + assert session.closed is True + assert adapter._http_session is None + + @pytest.mark.asyncio + async def test_disconnect_when_no_session(self) -> None: + adapter = _make_adapter() + # Should not raise. + await adapter.disconnect() diff --git a/tests/test_messenger_webhook.py b/tests/test_messenger_webhook.py new file mode 100644 index 00000000..0f874cdb --- /dev/null +++ b/tests/test_messenger_webhook.py @@ -0,0 +1,1105 @@ +"""Tests for the Messenger adapter — webhook routing & signature verification. + +Mirrors the upstream ``packages/adapter-messenger/src/index.test.ts`` suite +focused on the webhook surface: GET verification challenge, POST event +dispatch, X-Hub-Signature-256 verification (valid / invalid / missing / +replay), payload parsing, and event-type routing (messages, echoes, +postbacks, reactions, delivery / read confirmations, mixed batches). + +Pairs with ``tests/test_messenger_api.py`` (Graph API send/stream/error +mapping) — same split used for Telegram and WhatsApp. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +from dataclasses import dataclass +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from chat_sdk.adapters.messenger.adapter import ( + MessengerAdapter, + create_messenger_adapter, +) +from chat_sdk.adapters.messenger.types import ( + ENV_APP_SECRET, + ENV_PAGE_ACCESS_TOKEN, + ENV_VERIFY_TOKEN, + MessengerAdapterConfig, +) +from chat_sdk.logger import ConsoleLogger +from chat_sdk.shared.errors import ValidationError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +APP_SECRET = "test-app-secret" +PAGE_TOKEN = "test-page-token" +VERIFY_TOKEN = "test-verify-token" + + +def _make_adapter(**overrides: Any) -> MessengerAdapter: + """Create a ``MessengerAdapter`` with minimal valid config.""" + config_kwargs: dict[str, Any] = { + "app_secret": APP_SECRET, + "page_access_token": PAGE_TOKEN, + "verify_token": VERIFY_TOKEN, + "user_name": "test-bot", + "logger": ConsoleLogger("error"), + } + config_kwargs.update(overrides) + return MessengerAdapter(MessengerAdapterConfig(**config_kwargs)) + + +def _make_chat() -> MagicMock: + """Build a ChatInstance-shaped mock with the methods adapters use.""" + chat = MagicMock() + chat.get_user_name.return_value = "TestBot" + chat.process_message = MagicMock() + chat.process_action = MagicMock() + chat.process_reaction = MagicMock() + return chat + + +@dataclass +class _FakeRequest: + """Minimal request-like object accepted by ``handle_webhook``. + + Mirrors the framework-agnostic duck-typed shape used by the WhatsApp / + Telegram adapters (``url``, ``method``, ``headers``, awaitable ``text``). + """ + + url: str + method: str + _body: str + headers: dict[str, str] + + async def text(self) -> str: # noqa: D102 — async body getter + return self._body + + +def _sign(body: bytes | str, secret: str = APP_SECRET) -> str: + """Compute the X-Hub-Signature-256 header value for ``body``.""" + body_bytes = body if isinstance(body, bytes) else body.encode("utf-8") + return "sha256=" + hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest() + + +def _sample_event(**overrides: Any) -> dict[str, Any]: + """Build a representative inbound Messenger messaging event.""" + base: dict[str, Any] = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "message": {"mid": "mid.abc123", "text": "hello"}, + } + base.update(overrides) + return base + + +def _webhook_payload(events: list[dict[str, Any]]) -> dict[str, Any]: + return { + "object": "page", + "entry": [ + { + "id": "PAGE_456", + "time": 1735689600000, + "messaging": events, + } + ], + } + + +def _post_request(payload: dict[str, Any], *, sign: bool = True, signature: str | None = None) -> _FakeRequest: + body = json.dumps(payload) + headers: dict[str, str] = {"content-type": "application/json"} + if signature is not None: + headers["x-hub-signature-256"] = signature + elif sign: + headers["x-hub-signature-256"] = _sign(body) + return _FakeRequest(url="https://example.com/webhook", method="POST", _body=body, headers=headers) + + +# --------------------------------------------------------------------------- +# Factory and env-fallback behavior (Q1) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _clear_messenger_env() -> Any: + """Save and clear FACEBOOK_* env vars for the duration of a test.""" + saved: dict[str, str | None] = { + k: os.environ.pop(k, None) for k in (ENV_APP_SECRET, ENV_PAGE_ACCESS_TOKEN, ENV_VERIFY_TOKEN) + } + yield + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +class TestFactory: + """``create_messenger_adapter`` env fallbacks and Q1 init failure path.""" + + def test_with_explicit_params(self, _clear_messenger_env: Any) -> None: + adapter = create_messenger_adapter( + app_secret="s", + page_access_token="t", + verify_token="v", + user_name="mybot", + ) + assert adapter.name == "messenger" + assert adapter.user_name == "mybot" + + def test_uses_env_vars_when_omitted(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "secret" + os.environ[ENV_PAGE_ACCESS_TOKEN] = "token" + os.environ[ENV_VERIFY_TOKEN] = "verify" + adapter = create_messenger_adapter() + assert isinstance(adapter, MessengerAdapter) + assert adapter.name == "messenger" + + def test_missing_app_secret_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_PAGE_ACCESS_TOKEN] = "t" + os.environ[ENV_VERIFY_TOKEN] = "v" + with pytest.raises(ValidationError, match="appSecret"): + create_messenger_adapter() + + def test_missing_page_access_token_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "s" + os.environ[ENV_VERIFY_TOKEN] = "v" + with pytest.raises(ValidationError, match="pageAccessToken"): + create_messenger_adapter() + + def test_missing_verify_token_raises(self, _clear_messenger_env: Any) -> None: + os.environ[ENV_APP_SECRET] = "s" + os.environ[ENV_PAGE_ACCESS_TOKEN] = "t" + with pytest.raises(ValidationError, match="verifyToken"): + create_messenger_adapter() + + def test_constructor_also_raises_on_missing_credentials(self, _clear_messenger_env: Any) -> None: + """Q1: constructing the adapter directly with missing creds also fails. + + The constructor goes through the same ``resolved_*`` fallback chain as + the factory, matching the WhatsApp adapter's behavior and surfacing + config errors loudly at startup rather than at first webhook call. + """ + with pytest.raises(ValidationError, match="appSecret"): + MessengerAdapter( + MessengerAdapterConfig( + page_access_token="t", + verify_token="v", + logger=ConsoleLogger("error"), + ) + ) + + def test_explicit_empty_user_name_is_respected(self, _clear_messenger_env: Any) -> None: + """Regression: ``user_name=""`` must be treated as an explicit choice. + + The old ``config.user_name or "bot"`` (paired with + ``bool(config.user_name)``) silently replaced an explicit empty + string with the ``"bot"`` default and left ``_has_explicit_user_name`` + ``False`` — so ``initialize()`` would then overwrite it from + ``chat.get_user_name()`` / ``/me``. Switching both sites to + ``is not None`` honors the explicit empty string. + + Without the fix this test would observe ``_user_name == "bot"`` and + ``_has_explicit_user_name is False``. + """ + adapter = MessengerAdapter( + MessengerAdapterConfig( + app_secret=APP_SECRET, + page_access_token=PAGE_TOKEN, + verify_token=VERIFY_TOKEN, + user_name="", + logger=ConsoleLogger("error"), + ) + ) + assert adapter._user_name == "" + assert adapter._has_explicit_user_name is True + + +# --------------------------------------------------------------------------- +# Thread ID encoding +# --------------------------------------------------------------------------- + + +class TestThreadId: + """Encode / decode of Messenger thread IDs.""" + + def test_encode(self) -> None: + from chat_sdk.adapters.messenger.types import MessengerThreadId + + adapter = _make_adapter() + assert adapter.encode_thread_id(MessengerThreadId(recipient_id="USER_123")) == "messenger:USER_123" + + def test_decode(self) -> None: + adapter = _make_adapter() + decoded = adapter.decode_thread_id("messenger:USER_123") + assert decoded.recipient_id == "USER_123" + + def test_decode_rejects_invalid_prefix(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("invalid") + + def test_decode_rejects_empty_recipient(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("messenger:") + + def test_decode_rejects_extra_colons(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("messenger:foo:bar") + + def test_decode_rejects_empty(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("") + + def test_decode_rejects_wrong_platform(self) -> None: + adapter = _make_adapter() + with pytest.raises(ValidationError): + adapter.decode_thread_id("slack:C123:ts") + + def test_is_dm_always_true(self) -> None: + adapter = _make_adapter() + assert adapter.is_dm("messenger:anything") is True + + def test_channel_id_equals_thread_id(self) -> None: + adapter = _make_adapter() + assert adapter.channel_id_from_thread_id("messenger:USER_123") == "messenger:USER_123" + + @pytest.mark.asyncio + async def test_open_dm_returns_encoded(self) -> None: + adapter = _make_adapter() + tid = await adapter.open_dm("USER_999") + assert tid == "messenger:USER_999" + + +# --------------------------------------------------------------------------- +# GET verification challenge (Q3-adjacent — token equality check) +# --------------------------------------------------------------------------- + + +class TestGetVerification: + """Webhook subscription verification (``hub.mode=subscribe``).""" + + @pytest.mark.asyncio + async def test_valid_verification_request(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=subscribe&hub.verify_token={VERIFY_TOKEN}&hub.challenge=CHALLENGE_VALUE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "CHALLENGE_VALUE" + + @pytest.mark.asyncio + async def test_invalid_verify_token_rejected(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url="https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong&hub.challenge=CHALLENGE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_missing_challenge_yields_empty_body(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=subscribe&hub.verify_token={VERIFY_TOKEN}", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "" + + @pytest.mark.asyncio + async def test_wrong_mode_rejected(self) -> None: + adapter = _make_adapter() + request = _FakeRequest( + url=f"https://example.com/webhook?hub.mode=unsubscribe&hub.verify_token={VERIFY_TOKEN}&hub.challenge=CHALLENGE", + method="GET", + _body="", + headers={}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + +# --------------------------------------------------------------------------- +# Signature verification (Q3) +# --------------------------------------------------------------------------- + + +class TestVerifySignature: + """Unit tests for ``_verify_signature``. + + Q3: upstream pins X-Hub-Signature-256 with HMAC-SHA256 over the raw body + using the App Secret. We mirror that exactly; a swappable verifier (like + Slack's ``webhook_verifier``) would diverge from Meta's protocol and + isn't justified for the single-secret Meta integration. + """ + + def test_valid_signature_accepted(self) -> None: + adapter = _make_adapter(app_secret="my-secret") + body = b'{"test": true}' + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, sig) is True + + def test_uppercase_hex_signature_accepted(self) -> None: + """An uppercase-hex signature must still verify. + + ``hexdigest()`` is lowercase, but Node's ``Buffer.from(hex)`` is + case-insensitive, so upstream accepts an uppercase-hex signature. + We normalize the header hash to lowercase before the constant-time + compare; without that, this exact-but-uppercased signature would be + rejected. Pins parity with upstream's behavior. + """ + adapter = _make_adapter(app_secret="my-secret") + body = b'{"test": true}' + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest().upper() + assert adapter._verify_signature(body, sig) is True + + def test_invalid_signature_rejected(self) -> None: + adapter = _make_adapter(app_secret="my-secret") + assert adapter._verify_signature(b'{"a":1}', "sha256=deadbeef") is False + + def test_missing_signature_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", None) is False + + def test_empty_signature_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "") is False + + def test_wrong_algo_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "sha1=abcdef") is False + + def test_missing_hash_after_algo_rejected(self) -> None: + adapter = _make_adapter() + assert adapter._verify_signature(b"body", "sha256=") is False + + def test_signature_without_equals_rejected(self) -> None: + adapter = _make_adapter() + # No "=" separator at all is malformed. + assert adapter._verify_signature(b"body", "abc123") is False + + def test_signature_with_wrong_secret_rejected(self) -> None: + """A signature computed with the wrong key must not validate. + + Equivalent of a "replay with attacker's secret" / forgery attempt — + even with a valid-looking sha256 hex shape, mismatching HMAC fails. + """ + adapter = _make_adapter(app_secret="real-secret") + body = b'{"x":1}' + forged = "sha256=" + hmac.new(b"other-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, forged) is False + + def test_replay_with_modified_body_rejected(self) -> None: + """Capturing a signature and replaying it with a mutated body fails. + + Models the classic "attacker replays old signature against new body" + scenario — HMAC binds the signature to the exact bytes that were + signed, so any change to the body invalidates the signature. + """ + adapter = _make_adapter() + original_body = json.dumps(_webhook_payload([_sample_event()])).encode("utf-8") + sig = _sign(original_body) + # Same signature, body mutated (e.g. attacker injects another event). + mutated_body = json.dumps(_webhook_payload([_sample_event(), _sample_event()])).encode("utf-8") + assert adapter._verify_signature(mutated_body, sig) is False + + def test_verifies_raw_bytes_without_encoding_roundtrip(self) -> None: + """Signature must be checked against the exact wire bytes. + + Meta signs the raw HTTP request body. If the adapter re-encodes the + body (decode-to-str then encode-back-to-bytes) before HMAC, any byte + sequence that's not valid UTF-8 gets replaced with U+FFFD and the + computed HMAC diverges from Meta's — legitimate webhooks would fail + verification. This regression pins the contract: feed a body whose + bytes don't round-trip through UTF-8 cleanly, and verification still + succeeds when the signature was computed over the original bytes. + """ + adapter = _make_adapter(app_secret="my-secret") + # Lone continuation byte (0x80) — invalid UTF-8. A decode+re-encode + # round-trip via "utf-8" with errors="replace" would mutate this to + # the 3-byte U+FFFD sequence, breaking HMAC parity. + body = b'{"data":"\x80\xff"}' + assert body.decode("utf-8", errors="replace").encode("utf-8") != body + sig = "sha256=" + hmac.new(b"my-secret", body, hashlib.sha256).hexdigest() + assert adapter._verify_signature(body, sig) is True + + +class TestGetRequestBody: + """Pin that ``_get_request_body`` returns raw bytes. + + The function feeds ``_verify_signature``; switching its return type + would silently re-introduce the decode/re-encode hazard. These tests + cover the four frameworks-shaped inputs the helper supports + (bytes/str ``body`` attribute, bytes/str ``text`` attribute, async or + sync callables) and pin the bytes contract on each path. + """ + + @pytest.mark.asyncio + async def test_bytes_body_attribute_returned_unchanged(self) -> None: + adapter = _make_adapter() + raw = b'{"x":"\x80\xff"}' + + class Req: + body = raw + + result = await adapter._get_request_body(Req()) + assert result == raw + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_str_body_attribute_encoded_utf8(self) -> None: + adapter = _make_adapter() + + class Req: + body = '{"hello": "world"}' + + result = await adapter._get_request_body(Req()) + assert result == b'{"hello": "world"}' + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_async_callable_body_returns_bytes(self) -> None: + """Mirrors Starlette/FastAPI: ``await request.body()`` returns bytes.""" + adapter = _make_adapter() + raw = b'{"async": true}' + + async def body() -> bytes: + return raw + + class Req: + pass + + req = Req() + req.body = body # type: ignore[attr-defined] + result = await adapter._get_request_body(req) + assert result == raw + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_text_attribute_fallback_returns_bytes(self) -> None: + """aiohttp-style: ``await request.text()`` returns str — encode it.""" + adapter = _make_adapter() + + async def text() -> str: + return '{"text": "ok"}' + + class Req: + pass + + req = Req() + req.text = text # type: ignore[attr-defined] + result = await adapter._get_request_body(req) + assert result == b'{"text": "ok"}' + assert isinstance(result, bytes) + + @pytest.mark.asyncio + async def test_missing_body_returns_empty_bytes(self) -> None: + adapter = _make_adapter() + + class Req: + pass + + result = await adapter._get_request_body(Req()) + assert result == b"" + assert isinstance(result, bytes) + + +# --------------------------------------------------------------------------- +# POST webhook — payload validation & signature gate +# --------------------------------------------------------------------------- + + +class TestWebhookPostSignatureGate: + """End-to-end signature gating in ``handle_webhook`` (POST).""" + + @pytest.mark.asyncio + async def test_missing_signature_header_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), sign=False) + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_wrong_algo_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha1=abc123") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_missing_hash_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha256=") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_bad_hex_rejected(self) -> None: + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()]), signature="sha256=not-valid-hex") + response = await adapter.handle_webhook(request) + assert response["status"] == 403 + + @pytest.mark.asyncio + async def test_valid_signature_returns_event_received(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() # bypass full init + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "EVENT_RECEIVED" + + +class TestWebhookPostPayloadValidation: + """Payload-shape validation after signature passes.""" + + @pytest.mark.asyncio + async def test_invalid_json_returns_400(self) -> None: + adapter = _make_adapter() + body = "not valid json{{{" + request = _FakeRequest( + url="https://example.com/webhook", + method="POST", + _body=body, + headers={"content-type": "application/json", "x-hub-signature-256": _sign(body)}, + ) + response = await adapter.handle_webhook(request) + assert response["status"] == 400 + + @pytest.mark.asyncio + async def test_non_page_object_returns_404(self) -> None: + adapter = _make_adapter() + request = _post_request({"object": "user", "entry": []}) + response = await adapter.handle_webhook(request) + assert response["status"] == 404 + + @pytest.mark.asyncio + async def test_uninitialized_chat_returns_200_and_warns(self) -> None: + """Match upstream: webhooks that arrive before ``initialize`` ack 200. + + Returning non-200 would cause Meta to retry — and the only thing the + adapter knows at this point is that it's not ready to dispatch, not + that the payload is bad. Acking matches upstream. + """ + adapter = _make_adapter() + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert response["body"] == "EVENT_RECEIVED" + + +# --------------------------------------------------------------------------- +# Event routing +# --------------------------------------------------------------------------- + + +class TestWebhookMessageRouting: + """Routing of inbound message events to ``chat.process_message``.""" + + @pytest.mark.asyncio + async def test_incoming_message_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + request = _post_request(_webhook_payload([_sample_event()])) + response = await adapter.handle_webhook(request) + assert response["status"] == 200 + assert chat.process_message.call_count == 1 + call_args = chat.process_message.call_args + # Signature: (adapter, thread_id, message, options) + assert call_args.args[1] == "messenger:USER_123" + + @pytest.mark.asyncio + async def test_echo_message_not_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = _sample_event(message={"mid": "mid.echo", "text": "bot", "is_echo": True}) + request = _post_request(_webhook_payload([event])) + await adapter.handle_webhook(request) + assert chat.process_message.call_count == 0 + + @pytest.mark.asyncio + async def test_echo_message_cached_for_history(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = _sample_event( + sender={"id": "PAGE_456"}, + recipient={"id": "USER_123"}, + message={"mid": "mid.echo1", "text": "bot reply", "is_echo": True}, + ) + request = _post_request(_webhook_payload([event])) + await adapter.handle_webhook(request) + cached = await adapter.fetch_message("messenger:USER_123", "mid.echo1") + assert cached is not None + assert cached.text == "bot reply" + + @pytest.mark.asyncio + async def test_multiple_messages_in_entry(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = _webhook_payload( + [ + _sample_event(message={"mid": "mid.1", "text": "first"}), + _sample_event(message={"mid": "mid.2", "text": "second"}), + _sample_event(message={"mid": "mid.3", "text": "third"}), + ] + ) + await adapter.handle_webhook(_post_request(payload)) + assert chat.process_message.call_count == 3 + + @pytest.mark.asyncio + async def test_multiple_entries_in_payload(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = { + "object": "page", + "entry": [ + { + "id": "PAGE_456", + "time": 1735689600000, + "messaging": [_sample_event(message={"mid": "mid.a", "text": "from 1"})], + }, + { + "id": "PAGE_456", + "time": 1735689601000, + "messaging": [_sample_event(message={"mid": "mid.b", "text": "from 2"})], + }, + ], + } + await adapter.handle_webhook(_post_request(payload)) + assert chat.process_message.call_count == 2 + + @pytest.mark.asyncio + async def test_delivery_confirmation_no_error(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() + event = _sample_event(message=None, delivery={"watermark": 1735689600000, "mids": ["mid.abc"]}) + # The TypedDict allows None for missing keys; remove ``message`` key. + event.pop("message") + response = await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert response["status"] == 200 + + @pytest.mark.asyncio + async def test_read_confirmation_no_error(self) -> None: + adapter = _make_adapter() + adapter._chat = _make_chat() + event = _sample_event(read={"watermark": 1735689600000}) + event.pop("message") + response = await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert response["status"] == 200 + + @pytest.mark.asyncio + async def test_mixed_event_types(self) -> None: + """Single payload with message + reaction + delivery + read.""" + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + payload = _webhook_payload( + [ + _sample_event(message={"mid": "mid.msg", "text": "hi"}), + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "mid.msg", + "action": "react", + "emoji": "❤", + "reaction": "love", + }, + }, + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "delivery": {"watermark": 1735689600000, "mids": ["mid.msg"]}, + }, + { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "read": {"watermark": 1735689600000}, + }, + ] + ) + response = await adapter.handle_webhook(_post_request(payload)) + assert response["status"] == 200 + assert chat.process_message.call_count == 1 + assert chat.process_reaction.call_count == 1 + + +class TestPostbackRouting: + """Postback events dispatch via ``chat.process_action``.""" + + @pytest.mark.asyncio + async def test_postback_dispatched(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Get Started", "payload": "GET_STARTED"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + assert chat.process_action.call_count == 1 + + @pytest.mark.asyncio + async def test_postback_mid_used_when_present(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Menu", "payload": "MENU_1", "mid": "mid.postback1"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.message_id == "mid.postback1" + assert action.action_id == "MENU_1" + # Q2 (PR 1): callback-data passthrough — when no chat: prefix, both + # action_id and value come from the raw payload. + assert action.value == "MENU_1" + + @pytest.mark.asyncio + async def test_postback_falls_back_to_timestamp_id(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689999000, + "postback": {"title": "Get Started", "payload": "GET_STARTED"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.message_id == "postback:1735689999000" + + @pytest.mark.asyncio + async def test_postback_with_chat_prefixed_payload(self) -> None: + """Postbacks with the ``chat:`` prefix decode into (action, value).""" + from chat_sdk.adapters.messenger.cards import encode_messenger_callback_data + + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + encoded = encode_messenger_callback_data("approve", "yes") + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "postback": {"title": "Approve", "payload": encoded, "mid": "mid.p"}, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + action = chat.process_action.call_args.args[0] + assert action.action_id == "approve" + assert action.value == "yes" + + +class TestReactionRouting: + """Reaction events dispatch via ``chat.process_reaction``.""" + + @pytest.mark.asyncio + async def test_react_event(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "m_reacted", + "action": "react", + "emoji": "❤", + "reaction": "love", + }, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + reaction = chat.process_reaction.call_args.args[0] + assert reaction.message_id == "m_reacted" + assert reaction.raw_emoji == "❤" + assert reaction.added is True + + @pytest.mark.asyncio + async def test_unreact_event(self) -> None: + adapter = _make_adapter() + chat = _make_chat() + adapter._chat = chat + event = { + "sender": {"id": "USER_123"}, + "recipient": {"id": "PAGE_456"}, + "timestamp": 1735689600000, + "reaction": { + "mid": "m_reacted", + "action": "unreact", + "emoji": "❤", + "reaction": "love", + }, + } + await adapter.handle_webhook(_post_request(_webhook_payload([event]))) + reaction = chat.process_reaction.call_args.args[0] + assert reaction.added is False + + +# --------------------------------------------------------------------------- +# Attachment parsing (no network) +# --------------------------------------------------------------------------- + + +class TestAttachmentParsing: + """``parse_message`` extracts attachments from inbound events.""" + + def test_extracts_image_video_audio_file_fallback(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.attach", + "text": "check", + "attachments": [ + {"type": "image", "payload": {"url": "https://example.com/img.jpg"}}, + {"type": "video", "payload": {"url": "https://example.com/vid.mp4"}}, + {"type": "audio", "payload": {"url": "https://example.com/aud.mp3"}}, + {"type": "file", "payload": {"url": "https://example.com/doc.pdf"}}, + {"type": "fallback", "payload": {"url": "https://example.com/fb"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert len(parsed.attachments) == 5 + assert [a.type for a in parsed.attachments] == ["image", "video", "audio", "file", "file"] + + def test_skips_attachments_without_url(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.nourl", + "text": "sticker", + "attachments": [ + {"type": "image", "payload": {"sticker_id": 123}}, + {"type": "image"}, + ], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments == [] + + def test_location_attachment_mapped_to_file(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.loc", + "text": "location", + "attachments": [ + {"type": "location", "payload": {"url": "https://maps.example.com/x"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert len(parsed.attachments) == 1 + assert parsed.attachments[0].type == "file" + + def test_no_attachments_field(self) -> None: + adapter = _make_adapter() + event = _sample_event(message={"mid": "mid.no", "text": "plain"}) + parsed = adapter.parse_message(event) + assert parsed.attachments == [] + + def test_attachment_has_fetch_data_callable(self) -> None: + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.fd", + "text": "x", + "attachments": [{"type": "image", "payload": {"url": "https://example.com/img.jpg"}}], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments[0].fetch_data is not None + assert callable(parsed.attachments[0].fetch_data) + + @pytest.mark.asyncio + async def test_attachment_download_uses_session(self) -> None: + """``fetch_data`` reads bytes from the shared aiohttp session.""" + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.dl", + "text": "x", + "attachments": [{"type": "image", "payload": {"url": "https://example.com/img.jpg"}}], + } + ) + parsed = adapter.parse_message(event) + + # Patch the shared session with a minimal context-manager mock. + class _Resp: + status = 200 + + async def read(self) -> bytes: + return b"image-bytes" + + async def __aenter__(self) -> _Resp: + return self + + async def __aexit__(self, *_: object) -> None: + pass + + # aiohttp's session.get(url) is a sync call returning an + # async-context-manager request handle. Wire side_effect (instead of + # assigning a fresh mock to .get) so the call returns _Resp directly. + def _session_get(url: str, **_kw: object) -> _Resp: + return _Resp() + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.side_effect = _session_get + adapter._http_session = mock_session + + result = await parsed.attachments[0].fetch_data() + assert result == b"image-bytes" + + +# --------------------------------------------------------------------------- +# Attachment rehydration (queue/debounce/burst concurrency) +# --------------------------------------------------------------------------- + + +class TestRehydrateAttachment: + """``MessengerAdapter.rehydrate_attachment`` rebuilds dropped closures. + + Ports the WhatsApp adapter's ``rehydrate_attachment`` hook to Messenger + so that messages bearing attachments survive the queue/debounce/burst + concurrency paths (Codex P2 finding). Without the hook, ``fetch_data`` + is ``None`` after dequeue and downstream handlers cannot download bytes. + """ + + def test_extraction_populates_fetch_metadata_with_url(self) -> None: + """``_extract_attachments`` stores the download URL on ``fetch_metadata``.""" + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.meta", + "text": "x", + "attachments": [ + {"type": "image", "payload": {"url": "https://scontent.example.com/img.jpg"}}, + ], + } + ) + parsed = adapter.parse_message(event) + assert parsed.attachments[0].fetch_metadata == {"url": "https://scontent.example.com/img.jpg"} + + @pytest.mark.asyncio + async def test_rehydrate_rebuilds_fetch_data_after_queue_roundtrip(self) -> None: + """Simulate the queue/serialize cycle and confirm rehydration restores downloads. + + This is the load-bearing test: it would FAIL if ``rehydrate_attachment`` + is left as the protocol default (no-op) because ``fetch_data`` would + stay ``None`` and the ``await`` would raise ``TypeError``. + """ + adapter = _make_adapter() + event = _sample_event( + message={ + "mid": "mid.rehy", + "text": "x", + "attachments": [ + {"type": "image", "payload": {"url": "https://scontent.example.com/img.jpg"}}, + ], + } + ) + parsed = adapter.parse_message(event) + original = parsed.attachments[0] + + # Simulate the JSON roundtrip: ``fetch_data`` closure is dropped, + # ``fetch_metadata`` survives (it serializes as a plain dict). + original.fetch_data = None + + rehydrated = adapter.rehydrate_attachment(original) + assert rehydrated.fetch_data is not None + assert callable(rehydrated.fetch_data) + # Preserves the metadata so a second roundtrip would still work. + assert rehydrated.fetch_metadata == {"url": "https://scontent.example.com/img.jpg"} + + # Wire a fake session to capture the URL the rebuilt closure hits. + captured_urls: list[str] = [] + + class _Resp: + status = 200 + + async def read(self) -> bytes: + return b"rehydrated-bytes" + + async def __aenter__(self) -> _Resp: + return self + + async def __aexit__(self, *_: object) -> None: + pass + + def _session_get(url: str, **_kw: object) -> _Resp: + captured_urls.append(url) + return _Resp() + + mock_session = MagicMock() + mock_session.closed = False + mock_session.get.side_effect = _session_get + adapter._http_session = mock_session + + data = await rehydrated.fetch_data() + assert data == b"rehydrated-bytes" + assert captured_urls == ["https://scontent.example.com/img.jpg"] + + def test_rehydrate_no_metadata_returns_unchanged(self) -> None: + """Degraded mode: attachment without ``fetch_metadata`` is returned as-is.""" + adapter = _make_adapter() + from chat_sdk.types import Attachment + + bare = Attachment(type="image", url="https://example.com/x.jpg") + # fetch_metadata is None by default — adapter cannot rebuild the closure. + result = adapter.rehydrate_attachment(bare) + assert result is bare + assert result.fetch_data is None + + def test_rehydrate_metadata_missing_url_returns_unchanged(self) -> None: + """Degraded mode: ``fetch_metadata`` without the ``url`` key is a no-op.""" + adapter = _make_adapter() + from chat_sdk.types import Attachment + + bare = Attachment( + type="image", + url="https://example.com/x.jpg", + fetch_metadata={"unrelated": "value"}, + ) + result = adapter.rehydrate_attachment(bare) + assert result is bare + assert result.fetch_data is None diff --git a/tests/test_serialization.py b/tests/test_serialization.py index f48a46aa..217f0040 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -702,7 +702,7 @@ def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_ "_adapter_name": original._adapter_name, "_state_adapter_instance": original._state_adapter_instance, "_channel_cache": original._channel_cache, - "_message_history": original._message_history, + "_thread_history": original._thread_history, "_recent_messages": list(original._recent_messages), "_is_subscribed_context": original._is_subscribed_context, } @@ -720,10 +720,10 @@ def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_ assert original._adapter is snapshot["_adapter"] assert original._state_adapter_instance is snapshot["_state_adapter_instance"] - def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state): + def test_should_invalidate_recent_messages_and_thread_history_on_rebind(self, mock_state): """Two additional caches that carry previous-binding references must also drop on idempotent rebind: `_recent_messages` (populated from - adapter fetches) and `_message_history` (tied to chat's cache). + adapter fetches) and `_thread_history` (tied to chat's cache). Regression for a self-review-subagent finding.""" from chat_sdk.testing import create_mock_adapter, create_mock_state, create_test_message @@ -732,7 +732,7 @@ def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, m first_state = create_mock_state() # Prime caches: initial_message populates _recent_messages, and - # message_history is a sentinel cache object. + # thread_history is a sentinel cache object. sentinel_history = object() original = ThreadImpl( _ThreadImplConfig( @@ -741,15 +741,15 @@ def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, m state_adapter=first_state, channel_id="C123", initial_message=create_test_message("m1", "hello"), - message_history=sentinel_history, + thread_history=sentinel_history, ) ) assert len(original._recent_messages) == 1 - assert original._message_history is sentinel_history + assert original._thread_history is sentinel_history rebound = ThreadImpl.from_json(original, adapter=second_adapter) assert rebound._recent_messages == [], "recent_messages should drop on rebind" - assert rebound._message_history is None, "message_history should drop on rebind" + assert rebound._thread_history is None, "thread_history should drop on rebind" def test_should_set_state_from_chat_when_both_adapter_and_chat_passed(self, mock_state): """adapter= and chat= are orthogonal. Passing both must apply both: diff --git a/tests/test_slack_api.py b/tests/test_slack_api.py index 4fd7e190..d39d7bad 100644 --- a/tests/test_slack_api.py +++ b/tests/test_slack_api.py @@ -331,6 +331,58 @@ async def test_thread_reply(self): calls = client.get_calls("chat_postMessage") assert calls[0]["kwargs"]["thread_ts"] == "1234567890.000000" + @pytest.mark.asyncio + async def test_markdown_message_posts_via_native_markdown_text(self): + """Markdown is passed through to Slack's markdown_text field for + native rendering -- not converted to mrkdwn ``text``.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.444444"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="**Bold** and _italic_ and `code`"), + ) + + calls = client.get_calls("chat_postMessage") + assert len(calls) == 1 + kwargs = calls[0]["kwargs"] + assert kwargs["markdown_text"] == "**Bold** and _italic_ and `code`" + # markdown_text is mutually exclusive with text. + assert "text" not in kwargs + + @pytest.mark.asyncio + async def test_plain_string_posts_via_text_not_markdown_text(self): + """Plain strings keep going to ``text`` so literal ``*`` survives.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.555555"}) + + await adapter.post_message("slack:C123:1234567890.000000", "Use *foo* literally") + + kwargs = client.get_calls("chat_postMessage")[0]["kwargs"] + assert kwargs["text"] == "Use *foo* literally" + assert "markdown_text" not in kwargs + + @pytest.mark.asyncio + async def test_markdown_table_posts_as_markdown_text_without_blocks(self): + """Tables ride along in markdown_text (Slack renders them natively); + the legacy native-table-block conversion is gone.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.666666"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="| A | B |\n|---|---|\n| 1 | 2 |"), + ) + + kwargs = client.get_calls("chat_postMessage")[0]["kwargs"] + assert kwargs["markdown_text"] == "| A | B |\n|---|---|\n| 1 | 2 |" + assert "blocks" not in kwargs + assert "text" not in kwargs + # ============================================================================= # editMessage Tests @@ -369,6 +421,83 @@ async def test_edit_message_returns_correct_thread_id(self): assert result.thread_id == "slack:CABC:1111.2222" + @pytest.mark.asyncio + async def test_edit_with_markdown_uses_markdown_text(self): + """chat.update sends markdown via markdown_text, like postMessage.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_update", {"ok": True, "ts": "1234567890.123456"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.edit_message( + "slack:C123:1234567890.000000", + "1234567890.123456", + PostableMarkdown(markdown="**Updated** body"), + ) + + kwargs = client.get_calls("chat_update")[0]["kwargs"] + assert kwargs["markdown_text"] == "**Updated** body" + assert "text" not in kwargs + + @pytest.mark.asyncio + async def test_edit_ephemeral_via_response_url_uses_mrkdwn_fallback(self): + """response_url payloads reject markdown_text (`no_text`), so + markdown/AST edits are rendered to legacy mrkdwn ``text``.""" + adapter, _, _ = await _init_adapter() + ephemeral_id = adapter._encode_ephemeral_message_id( + "1234567890.123456", "https://hooks.slack.com/respond", "U1" + ) + + recorded: dict[str, Any] = {} + + class FakeResponse: + is_success = True + status_code = 200 + text = "ok" + + class FakeAsyncClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + async def post(self, url: str, *, json: Any = None, headers: Any = None) -> FakeResponse: + recorded["url"] = url + recorded["json"] = json + return FakeResponse() + + # ``_send_to_response_url`` lazily imports httpx (optional dep, not + # installed in the test env) -- inject a stand-in module. + import sys + import types + + fake_httpx = types.ModuleType("httpx") + fake_httpx.AsyncClient = FakeAsyncClient # type: ignore[attr-defined] + original_module = sys.modules.get("httpx") + sys.modules["httpx"] = fake_httpx + try: + from chat_sdk.types import PostableMarkdown + + await adapter.edit_message( + "slack:C123:1234567890.000000", + ephemeral_id, + PostableMarkdown(markdown="**Updated** [text](https://example.com)\n\n| A | B |\n|---|---|\n| 1 | 2 |"), + ) + finally: + if original_module is not None: + sys.modules["httpx"] = original_module + else: + sys.modules.pop("httpx", None) + + assert recorded["url"] == "https://hooks.slack.com/respond" + body = recorded["json"] + assert body["replace_original"] is True + assert "*Updated* " in body["text"] + # Tables fall back to ASCII code blocks on this surface. + assert "```" in body["text"] + assert "markdown_text" not in body + # ============================================================================= # deleteMessage Tests @@ -670,6 +799,24 @@ async def test_handles_empty_message_ts_in_response(self): assert result.id == "" + @pytest.mark.asyncio + async def test_ephemeral_markdown_uses_markdown_text(self): + """chat.postEphemeral sends markdown via markdown_text too.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_postEphemeral", {"ok": True, "message_ts": "1234567890.777777"}) + + from chat_sdk.types import PostableMarkdown + + await adapter.post_ephemeral( + "slack:C123:1234567890.000000", + "U_USER_1", + PostableMarkdown(markdown="## Only for you"), + ) + + kwargs = client.get_calls("chat_postEphemeral")[0]["kwargs"] + assert kwargs["markdown_text"] == "## Only for you" + assert "text" not in kwargs + # ============================================================================= # scheduleMessage Tests @@ -722,6 +869,25 @@ async def test_cancel_scheduled_message(self): assert len(calls) == 1 assert calls[0]["kwargs"]["scheduled_message_id"] == "Q5678" + @pytest.mark.asyncio + async def test_schedule_markdown_uses_markdown_text(self): + """chat.scheduleMessage sends markdown via markdown_text too.""" + adapter, client, _ = await _init_adapter() + client.set_response("chat_scheduleMessage", {"ok": True, "scheduled_message_id": "Q9999"}) + + from chat_sdk.types import PostableMarkdown + + future_time = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc) + await adapter.schedule_message( + "slack:C123:1234567890.000000", + PostableMarkdown(markdown="**Reminder** tomorrow"), + future_time, + ) + + kwargs = client.get_calls("chat_scheduleMessage")[0]["kwargs"] + assert kwargs["markdown_text"] == "**Reminder** tomorrow" + assert "text" not in kwargs + @pytest.mark.asyncio async def test_rejects_past_time(self): adapter, client, _ = await _init_adapter() @@ -1316,6 +1482,23 @@ def test_renders_paragraph(self): ) assert "Hello world" in result + def test_renders_ast_to_standard_markdown(self): + """Slack now accepts markdown natively, so renderFormatted emits + standard markdown (``**bold**``), not legacy mrkdwn (``*bold*``).""" + adapter = _make_adapter() + result = adapter.render_formatted( + { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [{"type": "strong", "children": [{"type": "text", "value": "bold"}]}], + } + ], + } + ) + assert result.strip() == "**bold**" + # ============================================================================= # Link extraction edge cases @@ -1586,7 +1769,9 @@ async def text_gen() -> AsyncIterator[str | StreamChunk]: assert len(post_calls) == 1 assert post_calls[0]["kwargs"]["channel"] == "C123" assert post_calls[0]["kwargs"]["thread_ts"] is None - assert post_calls[0]["kwargs"]["text"] == "Hello from DM" + # Accumulated markdown goes out via Slack's native markdown_text field. + assert post_calls[0]["kwargs"]["markdown_text"] == "Hello from DM" + assert "text" not in post_calls[0]["kwargs"] assert result.id == "5555.5555" @pytest.mark.asyncio diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index 39dc26e7..16a9490c 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -1,6 +1,10 @@ -"""Tests for Slack markdown (mrkdwn) format conversion. +"""Tests for Slack format conversion. -Port of packages/adapter-slack/src/markdown.test.ts. +Port of packages/adapter-slack/src/markdown.test.ts (chat@4.29.0). + +Outgoing messages route through ``to_slack_payload`` (native ``markdown_text``) +or ``to_response_url_text`` (legacy mrkdwn -- response_url rejects +``markdown_text``). Incoming mrkdwn still parses via ``to_ast``. """ from __future__ import annotations @@ -8,166 +12,169 @@ from chat_sdk.adapters.slack.format_converter import SlackFormatConverter # --------------------------------------------------------------------------- -# fromMarkdown (markdown -> mrkdwn) +# toMarkdown (mrkdwn -> markdown) # --------------------------------------------------------------------------- -class TestFromMarkdown: +class TestToMarkdown: def setup_method(self): self.converter = SlackFormatConverter() def test_converts_bold(self): - assert self.converter.from_markdown("Hello **world**!") == "Hello *world*!" - - def test_converts_italic(self): - result = self.converter.from_markdown("Hello _world_!") - assert "_world_" in result + result = self.converter.to_markdown("Hello *world*!") + assert "**world**" in result def test_converts_strikethrough(self): - assert self.converter.from_markdown("Hello ~~world~~!") == "Hello ~world~!" + result = self.converter.to_markdown("Hello ~world~!") + assert "~~world~~" in result - def test_converts_links(self): - result = self.converter.from_markdown("Check [this](https://example.com)") - assert "" in result + def test_converts_links_with_text(self): + result = self.converter.to_markdown("Check ") + assert "[this](https://example.com)" in result - def test_preserves_inline_code(self): - result = self.converter.from_markdown("Use `const x = 1`") - assert "`const x = 1`" in result + def test_converts_bare_links(self): + result = self.converter.to_markdown("Visit ") + assert "https://example.com" in result - def test_handles_code_blocks(self): - result = self.converter.from_markdown("```js\nconst x = 1;\n```") - assert "```" in result - assert "const x = 1;" in result + def test_converts_user_mentions(self): + result = self.converter.to_markdown("Hey <@U123|john>!") + assert "@john" in result - def test_mixed_formatting(self): - result = self.converter.from_markdown("**Bold** and _italic_ and [link](https://x.com)") - assert "*Bold*" in result - assert "_italic_" in result - assert "" in result + def test_converts_channel_mentions(self): + result = self.converter.to_markdown("Join <#C123|general>") + assert "#general" in result + + def test_converts_bare_channel_mentions(self): + result = self.converter.to_markdown("Join <#C123>") + assert "#C123" in result # --------------------------------------------------------------------------- -# renderPostable — PostableMarkdown uses AST path (issue #81) +# toSlackPayload # --------------------------------------------------------------------------- -class TestRenderPostable: - """render_postable with PostableMarkdown must use the AST path (from_markdown), - not the regex _markdown_to_mrkdwn, to match the TS SDK's fromAst(parseMarkdown()) - behavior. Regression guard for issue #81. - """ - +class TestToSlackPayload: def setup_method(self): self.converter = SlackFormatConverter() - def test_postable_markdown_converts_link(self): - """[text](url) -> via AST, not regex.""" - from chat_sdk.types import PostableMarkdown - - result = self.converter.render_postable(PostableMarkdown(markdown="Check [this](https://example.com)")) - assert result == "Check " - - def test_dict_markdown_converts_link(self): - result = self.converter.render_postable({"markdown": "Check [this](https://example.com)"}) - assert result == "Check " - - def test_postable_markdown_converts_bold(self): - from chat_sdk.types import PostableMarkdown - - result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!")) - assert result == "Hello *world*!" - - def test_postable_markdown_converts_mixed(self): - from chat_sdk.types import PostableMarkdown + def test_routes_plain_strings_to_text_preserving_literal_markdown_chars(self): + assert self.converter.to_slack_payload("Use *foo* literally") == {"text": "Use *foo* literally"} + + def test_routes_raw_strings_to_text(self): + assert self.converter.to_slack_payload({"raw": "*already mrkdwn*"}) == {"text": "*already mrkdwn*"} + + def test_routes_markdown_to_markdown_text(self): + assert self.converter.to_slack_payload({"markdown": "## Heading\n\n- a\n- b"}) == { + "markdown_text": "## Heading\n\n- a\n- b" + } + + def test_routes_ast_to_markdown_text_via_stringify_markdown(self): + ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [{"type": "strong", "children": [{"type": "text", "value": "bold"}]}], + } + ], + } + result = self.converter.to_slack_payload({"ast": ast}) + assert "markdown_text" in result + assert "**bold**" in result["markdown_text"] + + def test_preserves_tables_when_rendering_ast_to_markdown_text(self): + ast = { + "type": "root", + "children": [ + { + "type": "table", + "align": [None, None], + "children": [ + { + "type": "tableRow", + "children": [ + {"type": "tableCell", "children": [{"type": "text", "value": "A"}]}, + {"type": "tableCell", "children": [{"type": "text", "value": "B"}]}, + ], + }, + { + "type": "tableRow", + "children": [ + {"type": "tableCell", "children": [{"type": "text", "value": "1"}]}, + {"type": "tableCell", "children": [{"type": "text", "value": "2"}]}, + ], + }, + ], + } + ], + } + result = self.converter.to_slack_payload({"ast": ast}) + assert "markdown_text" in result + lines = result["markdown_text"].strip().splitlines() + # Markdown pipe-table rows survive (not the legacy ASCII ``` fallback). + assert lines[0].startswith("|") + assert "A" in lines[0] and "B" in lines[0] + assert "1" in lines[2] and "2" in lines[2] + assert "```" not in result["markdown_text"] + + def test_postable_raw_dataclass_routes_to_text(self): + from chat_sdk.types import PostableRaw - result = self.converter.render_postable(PostableMarkdown(markdown="**Bold** and [link](https://x.com)")) - assert "*Bold*" in result - assert "" in result + assert self.converter.to_slack_payload(PostableRaw(raw="*already mrkdwn*")) == {"text": "*already mrkdwn*"} - def test_postable_markdown_link_with_query_string(self): - """URL with query params (no parens) converts correctly.""" + def test_postable_markdown_dataclass_routes_to_markdown_text(self): from chat_sdk.types import PostableMarkdown - result = self.converter.render_postable( - PostableMarkdown(markdown="See [results](https://example.com/search?q=foo&page=2)") - ) - assert "" in result - - def test_str_passthrough_only_converts_mentions(self): - """str input is treated as already-mrkdwn; only @mentions are wrapped.""" - result = self.converter.render_postable("Hello *world* and @george") - assert "*world*" in result - assert "<@george>" in result - - def test_postable_raw_bypasses_conversion(self): - """PostableRaw reaches Slack byte-for-byte (only mention wrapping).""" - from chat_sdk.types import PostableRaw - - result = self.converter.render_postable(PostableRaw(raw="Already *mrkdwn* text")) - assert result == "Already *mrkdwn* text" + result = self.converter.to_slack_payload(PostableMarkdown(markdown="**Bold** and [link](https://x.com)")) + assert result == {"markdown_text": "**Bold** and [link](https://x.com)"} - def test_dict_ast_converts_via_from_ast(self): - """{"ast": } is rendered via from_ast.""" + def test_postable_ast_dataclass_routes_to_markdown_text(self): from chat_sdk.shared.base_format_converter import parse_markdown + from chat_sdk.types import PostableAst - ast = parse_markdown("Hello **world**!") - result = self.converter.render_postable({"ast": ast}) - assert result == "Hello *world*!" - - def test_dict_card_uses_fallback_text(self): - """{"card": } extracts plain text via card_to_fallback_text.""" - card_payload = {"type": "card", "title": "My Card", "body": [{"type": "text", "text": "Card body"}]} - result = self.converter.render_postable({"card": card_payload}) - assert isinstance(result, str) - assert len(result) > 0 - - def test_object_with_card_attr_uses_fallback_text(self): - """Object with .card attribute extracts plain text via card_to_fallback_text.""" - - class FakeMessage: - card = {"type": "card", "title": "Attr Card", "body": [{"type": "text", "text": "body text"}]} + result = self.converter.to_slack_payload(PostableAst(ast=parse_markdown("Hello **world**!"))) + assert "markdown_text" in result + assert result["markdown_text"].strip() == "Hello **world**!" - result = self.converter.render_postable(FakeMessage()) - assert isinstance(result, str) - assert len(result) > 0 + def test_unrecognized_message_falls_back_to_empty_text(self): + assert self.converter.to_slack_payload({"something": "else"}) == {"text": ""} # --------------------------------------------------------------------------- -# toMarkdown (mrkdwn -> markdown) +# toResponseUrlText # --------------------------------------------------------------------------- -class TestToMarkdown: +class TestToResponseUrlText: def setup_method(self): self.converter = SlackFormatConverter() - def test_converts_bold(self): - result = self.converter.to_markdown("Hello *world*!") - assert "**world**" in result + def test_renders_markdown_to_slack_mrkdwn_text(self): + result = self.converter.to_response_url_text({"markdown": "**Bold** and [link](https://example.com)"}) + assert result == "*Bold* and " - def test_converts_strikethrough(self): - result = self.converter.to_markdown("Hello ~world~!") - assert "~~world~~" in result + def test_renders_markdown_tables_as_ascii_code_blocks(self): + result = self.converter.to_response_url_text({"markdown": "| A | B |\n|---|---|\n| 1 | 2 |"}) + assert "```\n" in result - def test_converts_links_with_text(self): - result = self.converter.to_markdown("Check ") - assert "[this](https://example.com)" in result + def test_plain_string_passes_through_with_mention_wrapping(self): + assert self.converter.to_response_url_text("Hey @george") == "Hey <@george>" - def test_converts_bare_links(self): - result = self.converter.to_markdown("Visit ") - assert "https://example.com" in result + def test_postable_markdown_dataclass_renders_to_mrkdwn(self): + from chat_sdk.types import PostableMarkdown - def test_converts_user_mentions(self): - result = self.converter.to_markdown("Hey <@U123|john>!") - assert "@john" in result + result = self.converter.to_response_url_text(PostableMarkdown(markdown="Hello **world**!")) + assert result == "Hello *world*!" - def test_converts_channel_mentions(self): - result = self.converter.to_markdown("Join <#C123|general>") - assert "#general" in result + def test_renders_nested_lists_with_slack_bullets(self): + """The mrkdwn list renderer (bullet + indent) is still live for the + response_url surface; markdown lists must not leak `-` markers.""" + result = self.converter.to_response_url_text({"markdown": "- parent\n - child 1\n - child 2"}) + assert result == "• parent\n • child 1\n • child 2" - def test_converts_bare_channel_mentions(self): - result = self.converter.to_markdown("Join <#C123>") - assert "#C123" in result + def test_unrecognized_message_falls_back_to_empty_string(self): + assert self.converter.to_response_url_text({"something": "else"}) == "" # --------------------------------------------------------------------------- @@ -179,42 +186,44 @@ class TestMentions: def setup_method(self): self.converter = SlackFormatConverter() - def test_no_double_wrap_existing_mentions(self): - result = self.converter.render_postable("Hey <@U12345>. Please select") - assert result == "Hey <@U12345>. Please select" + def test_no_double_wrap_existing_mentions_in_plain_strings(self): + assert self.converter.to_slack_payload("Hey <@U12345>. Please select") == { + "text": "Hey <@U12345>. Please select" + } - def test_no_double_wrap_mentions_in_markdown(self): - result = self.converter.render_postable({"markdown": "Hey <@U12345>. Please select"}) - assert result == "Hey <@U12345>. Please select" + def test_no_double_wrap_existing_mentions_in_markdown(self): + assert self.converter.to_slack_payload({"markdown": "Hey <@U12345>. Please select"}) == { + "markdown_text": "Hey <@U12345>. Please select" + } - def test_converts_bare_at_mentions(self): - result = self.converter.render_postable("Hey @george. Please select") - assert "<@george>" in result + def test_rewrites_bare_at_mentions_in_plain_strings(self): + assert self.converter.to_slack_payload("Hey @george. Please select") == {"text": "Hey <@george>. Please select"} - def test_converts_bare_mentions_in_markdown(self): - result = self.converter.render_postable({"markdown": "Hey @george. Please select"}) - assert "<@george>" in result + def test_rewrites_bare_at_mentions_in_markdown(self): + assert self.converter.to_slack_payload({"markdown": "Hey @george. Please select"}) == { + "markdown_text": "Hey <@george>. Please select" + } - def test_from_markdown_no_double_wrap(self): - result = self.converter.from_markdown("Hey <@U12345>") - assert result == "Hey <@U12345>" + def test_does_not_mangle_email_addresses_in_plain_strings(self): + assert self.converter.to_slack_payload("Contact user@example.com for help") == { + "text": "Contact user@example.com for help" + } - def test_does_not_wrap_at_in_email_localpart(self): - """`@` preceded by a word char is part of an email address, not a mention. + def test_does_not_mangle_email_addresses_in_markdown(self): + """`@` preceded by a word char is part of an email address, not a + mention -- and markdown now passes through unparsed, so the address + is not rewritten to a mailto autolink either.""" + assert self.converter.to_slack_payload({"markdown": "Contact alice@example.com"}) == { + "markdown_text": "Contact alice@example.com" + } - Without the lookbehind tightening, `alice@example.com` was rewritten to - `alice<@example>.com`, surfacing a broken-looking mention in messages - that quote email addresses from upstream APIs. - """ - result = self.converter.render_postable("Contact alice@example.com or bob@example.org") - assert "<@example>" not in result - assert "alice@example.com" in result - assert "bob@example.org" in result + def test_does_not_mangle_mailto_links(self): + assert self.converter.to_slack_payload("Email ") == { + "text": "Email " + } - def test_does_not_wrap_at_in_email_localpart_from_markdown_ast(self): - result = self.converter.render_postable({"markdown": "Contact alice@example.com"}) - assert "<@example>" not in result - assert "alice@example.com" in result + def test_converts_mentions_adjacent_to_non_word_punctuation(self): + assert self.converter.to_slack_payload("(cc @george, @anne)") == {"text": "(cc <@george>, <@anne>)"} # --------------------------------------------------------------------------- @@ -250,159 +259,45 @@ def test_handles_complex_messages(self): assert "<" not in result -# --------------------------------------------------------------------------- -# Table rendering -# --------------------------------------------------------------------------- - - -class TestTableRendering: +class TestExtractPlainTextAdditional: def setup_method(self): self.converter = SlackFormatConverter() - def test_renders_markdown_tables_as_code_blocks(self): - result = self.converter.from_markdown("| Name | Age |\n|------|-----|\n| Alice | 30 |") - assert "```" in result - assert "Name" in result - assert "Age" in result - assert "Alice" in result - assert "30" in result - - def test_preserves_table_structure_in_code_block(self): - result = self.converter.from_markdown("| A | B |\n|---|---|\n| 1 | 2 |") - assert result.startswith("```\n") - assert result.endswith("\n```") - + def test_removes_strikethrough_markers(self): + assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!" -# --------------------------------------------------------------------------- -# toBlocksWithTable -# --------------------------------------------------------------------------- + def test_extracts_bare_url(self): + assert self.converter.extract_plain_text("Visit ") == "Visit https://example.com" + def test_extracts_channel_mention_with_name(self): + assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general" -class TestToBlocksWithTable: - def setup_method(self): - self.converter = SlackFormatConverter() + def test_extracts_bare_channel_mention(self): + assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123" - def test_returns_none_when_no_tables(self): - ast = self.converter.to_ast("Hello world") - assert self.converter.to_blocks_with_table(ast) is None - - def test_returns_native_table_block(self): - ast = self.converter.to_ast("| Name | Age |\n|------|-----|\n| Alice | 30 |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 1 - assert blocks[0]["type"] == "table" - assert blocks[0]["rows"] == [ - [{"type": "raw_text", "text": "Name"}, {"type": "raw_text", "text": "Age"}], - [{"type": "raw_text", "text": "Alice"}, {"type": "raw_text", "text": "30"}], - ] - - def test_includes_surrounding_text(self): - md = "Here are the results:\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAll done." - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 3 - assert blocks[0]["type"] == "section" - assert "Here are the results" in blocks[0]["text"]["text"] - assert blocks[1]["type"] == "table" - assert blocks[2]["type"] == "section" - assert "All done" in blocks[2]["text"]["text"] - - def test_second_table_falls_back_to_ascii(self): - md = "| A | B |\n|---|---|\n| 1 | 2 |\n\n| C | D |\n|---|---|\n| 3 | 4 |" - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 2 - assert blocks[0]["type"] == "table" - assert blocks[1]["type"] == "section" - assert "```" in blocks[1]["text"]["text"] - - def test_should_replace_empty_table_cells_with_a_space_to_satisfy_slack_api(self): - ast = self.converter.to_ast("| Kind | Label |\n|------|-------|\n| FORM | Form Submission |\n| and more... | |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - table_block = blocks[0] - assert table_block["type"] == "table" - for row in table_block["rows"]: - for cell in row: - assert len(cell["text"]) > 0 - assert table_block["rows"][2][1]["text"] == " " - - def test_should_handle_empty_header_cells_with_parse_markdown_production_path(self): - from chat_sdk.shared.markdown_parser import parse_markdown - - markdown = "Here is a table:\n\n| | Header2 |\n|---------|----------|\n| Data1 | Data2 |" - ast = parse_markdown(markdown) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 2 - assert blocks[0]["type"] == "section" - assert blocks[1]["type"] == "table" - table_block = blocks[1] - assert table_block["rows"][0][0]["text"] == " " - for row in table_block["rows"]: - for cell in row: - assert len(cell["text"]) > 0 + def test_user_mention_with_name_extracted(self): + result = self.converter.extract_plain_text("Hey <@U123|john>!") + assert result == "Hey @john!" # --------------------------------------------------------------------------- -# Nested lists +# render_postable -- base-class behavior after the Slack override was removed # --------------------------------------------------------------------------- -class TestNestedLists: +class TestRenderPostableFallbacks: def setup_method(self): self.converter = SlackFormatConverter() - def test_indent_nested_unordered_lists(self): - result = self.converter.from_markdown("- parent\n - child 1\n - child 2") - assert "\u2022 parent" in result - assert " \u2022 child 1" in result - assert " \u2022 child 2" in result - - def test_indent_nested_ordered_lists(self): - result = self.converter.from_markdown("1. first\n 1. sub-first\n 2. sub-second\n2. second") - assert "1. first" in result - assert "sub-first" in result - assert "sub-second" in result - assert "2. second" in result - - def test_deeply_nested_lists(self): - result = self.converter.from_markdown("- level 1\n - level 2\n - level 3") - assert "\u2022 level 1" in result - assert "\u2022 level 2" in result - assert "\u2022 level 3" in result - - def test_sibling_items_same_indent(self): - result = self.converter.from_markdown("- item 1\n- item 2\n- item 3") - assert result == "\u2022 item 1\n\u2022 item 2\n\u2022 item 3" - - def test_mixed_ordered_and_unordered(self): - result = self.converter.from_markdown("1. first\n - sub a\n - sub b\n2. second") - assert "1. first" in result - assert "sub a" in result - assert "sub b" in result - assert "2. second" in result - - -# --------------------------------------------------------------------------- -# render_postable — remaining branch coverage -# --------------------------------------------------------------------------- - - -class TestRenderPostableRemainingBranches: - def setup_method(self): - self.converter = SlackFormatConverter() + def test_markdown_renders_to_standard_markdown(self): + """render_postable now goes through from_ast = stringify_markdown.""" + from chat_sdk.types import PostableMarkdown - def test_dict_raw_treated_as_mrkdwn_with_mention_wrapping(self): - """{"raw": ...} is treated as already-mrkdwn; only @mentions are wrapped.""" - result = self.converter.render_postable({"raw": "Already *mrkdwn* @george"}) - assert result == "Already *mrkdwn* <@george>" + result = self.converter.render_postable(PostableMarkdown(markdown="Hello **world**!")) + assert result.strip() == "Hello **world**!" def test_card_element_dict_renders_via_fallback_text(self): - """{"type": "card", ...} CardElement dict uses card_to_fallback_text.""" + """CardElement dicts still render via card_to_fallback_text.""" from chat_sdk.cards import Card card = Card(title="My Card") @@ -410,14 +305,14 @@ def test_card_element_dict_renders_via_fallback_text(self): assert "My Card" in result def test_object_with_ast_attr_renders_via_from_ast(self): - """Object with .ast attribute is rendered via from_ast.""" + """Object with .ast attribute is rendered to standard markdown.""" from chat_sdk.shared.base_format_converter import parse_markdown class FakeMsg: ast = parse_markdown("Hello **world**!") result = self.converter.render_postable(FakeMsg()) - assert result == "Hello *world*!" + assert result.strip() == "Hello **world**!" def test_arbitrary_object_falls_back_to_str(self): """Objects with no recognized attributes fall back to str().""" @@ -429,99 +324,33 @@ def __str__(self): result = self.converter.render_postable(Opaque()) assert result == "opaque output" - def test_multiple_at_mentions_in_str_all_wrapped(self): - """All bare @mentions in a str input are converted, not just the first.""" - result = self.converter.render_postable("Ping @alice and @bob please") - assert "<@alice>" in result - assert "<@bob>" in result - # --------------------------------------------------------------------------- -# _node_to_mrkdwn — individual node type rendering +# _node_to_mrkdwn -- node rendering on the response_url surface # --------------------------------------------------------------------------- -class TestNodeRendering: +class TestResponseUrlNodeRendering: def setup_method(self): self.converter = SlackFormatConverter() def test_heading_renders_as_bold(self): - assert self.converter.from_markdown("# My Heading") == "*My Heading*" - - def test_h2_heading_renders_as_bold(self): - assert self.converter.from_markdown("## Section Title") == "*Section Title*" + assert self.converter.to_response_url_text({"markdown": "# My Heading"}) == "*My Heading*" def test_blockquote_renders_with_gt_prefix(self): - result = self.converter.from_markdown("> quoted text") + result = self.converter.to_response_url_text({"markdown": "> quoted text"}) assert result == "> quoted text" def test_thematic_break_renders_as_dashes(self): - result = self.converter.from_markdown("before\n\n---\n\nafter") + result = self.converter.to_response_url_text({"markdown": "before\n\n---\n\nafter"}) assert "---" in result assert "before" in result assert "after" in result def test_image_with_alt_renders_alt_and_url(self): - result = self.converter.from_markdown("![alt text](https://example.com/img.png)") + result = self.converter.to_response_url_text({"markdown": "![alt text](https://example.com/img.png)"}) assert result == "alt text (https://example.com/img.png)" def test_image_without_alt_renders_url_only(self): - result = self.converter.from_markdown("![](https://example.com/img.png)") + result = self.converter.to_response_url_text({"markdown": "![](https://example.com/img.png)"}) assert result == "https://example.com/img.png" - - -# --------------------------------------------------------------------------- -# extract_plain_text — additional cases -# --------------------------------------------------------------------------- - - -class TestExtractPlainTextAdditional: - def setup_method(self): - self.converter = SlackFormatConverter() - - def test_removes_strikethrough_markers(self): - assert self.converter.extract_plain_text("Hello ~world~!") == "Hello world!" - - def test_extracts_bare_url(self): - assert self.converter.extract_plain_text("Visit ") == "Visit https://example.com" - - def test_extracts_channel_mention_with_name(self): - assert self.converter.extract_plain_text("Join <#C123|general>") == "Join #general" - - def test_extracts_bare_channel_mention(self): - assert self.converter.extract_plain_text("Join <#C123>") == "Join #C123" - - def test_user_mention_with_name_extracted(self): - result = self.converter.extract_plain_text("Hey <@U123|john>!") - assert result == "Hey @john!" - - -# --------------------------------------------------------------------------- -# to_blocks_with_table — additional cases -# --------------------------------------------------------------------------- - - -class TestToBlocksWithTableAdditional: - def setup_method(self): - self.converter = SlackFormatConverter() - - def test_returns_none_for_non_dict_ast(self): - assert self.converter.to_blocks_with_table("not a dict") is None # type: ignore[arg-type] - assert self.converter.to_blocks_with_table(None) is None # type: ignore[arg-type] - - def test_standalone_table_emits_no_extra_section_blocks(self): - """A table with no surrounding text produces exactly one block.""" - ast = self.converter.to_ast("| A | B |\n|---|---|\n| 1 | 2 |") - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - assert len(blocks) == 1 - assert blocks[0]["type"] == "table" - - def test_table_with_column_alignment_sets_column_settings(self): - """Aligned table columns produce column_settings on the table block.""" - md = "| Left | Center | Right |\n|:-----|:------:|------:|\n| a | b | c |" - ast = self.converter.to_ast(md) - blocks = self.converter.to_blocks_with_table(ast) - assert blocks is not None - settings = blocks[0].get("column_settings") - assert settings == [{"align": "left"}, {"align": "center"}, {"align": "right"}] diff --git a/tests/test_slack_installation_provider.py b/tests/test_slack_installation_provider.py new file mode 100644 index 00000000..1f97ebfa --- /dev/null +++ b/tests/test_slack_installation_provider.py @@ -0,0 +1,535 @@ +"""Tests for the Slack external installation provider (vercel/chat#467). + +Port of the ``describe("installationProvider")`` block from +packages/adapter-slack/src/index.test.ts (chat@4.29.0). + +When ``installation_provider`` is configured, per-installation token lookups +bypass the internal StateAdapter. For Enterprise Grid org-wide installs +(``is_enterprise_install``), the lookup key is the enterprise ID instead of +the team ID. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import time +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +try: + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig, SlackInstallation + from chat_sdk.shared.errors import AuthenticationError + from chat_sdk.types import Attachment + + _SLACK_AVAILABLE = True +except ImportError: + _SLACK_AVAILABLE = False + +pytestmark = pytest.mark.skipif(not _SLACK_AVAILABLE, reason="Slack adapter import failed") + +SECRET = "test-signing-secret" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeRequest: + def __init__(self, body: str, headers: dict[str, str]): + self.body = body.encode("utf-8") + self.headers = headers + self.url = "" + + async def text(self) -> str: + return self.body.decode("utf-8") + + +def _make_signed_request(body: str, content_type: str = "application/json") -> _FakeRequest: + ts = str(int(time.time())) + sig_base = f"v0:{ts}:{body}" + sig = "v0=" + hmac.new(SECRET.encode(), sig_base.encode(), hashlib.sha256).hexdigest() + return _FakeRequest( + body, + { + "x-slack-request-timestamp": ts, + "x-slack-signature": sig, + "content-type": content_type, + }, + ) + + +def _make_mock_state() -> MagicMock: + cache: dict[str, Any] = {} + state = MagicMock() + state.get = AsyncMock(side_effect=lambda k: cache.get(k)) + state.set = AsyncMock(side_effect=lambda k, v, *a, **kw: cache.__setitem__(k, v)) + state.delete = AsyncMock(side_effect=lambda k: cache.pop(k, None)) + state.append_to_list = AsyncMock() + state.get_list = AsyncMock(return_value=[]) + state._cache = cache + return state + + +def _make_mock_chat(state: MagicMock) -> MagicMock: + chat = MagicMock() + chat.process_message = MagicMock() + chat.handle_incoming_message = AsyncMock() + chat.process_reaction = MagicMock() + chat.process_action = MagicMock() + chat.process_modal_submit = AsyncMock() + chat.process_modal_close = MagicMock() + chat.process_slash_command = MagicMock() + chat.process_member_joined_channel = MagicMock() + chat.get_state = MagicMock(return_value=state) + chat.get_user_name = MagicMock(return_value="test-bot") + chat.get_logger = MagicMock(return_value=MagicMock()) + return chat + + +def _make_provider(installation: SlackInstallation | None) -> MagicMock: + provider = MagicMock() + provider.get_installation = AsyncMock(return_value=installation) + return provider + + +async def _make_provider_adapter( + provider: MagicMock, +) -> tuple[SlackAdapter, MagicMock, MagicMock]: + """Multi-workspace adapter (no bot_token) with an installation provider.""" + state = _make_mock_state() + chat = _make_mock_chat(state) + adapter = SlackAdapter(SlackAdapterConfig(signing_secret=SECRET, installation_provider=provider)) + await adapter.initialize(chat) + return adapter, chat, state + + +def _event_body(**envelope: Any) -> str: + body = { + "type": "event_callback", + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + "username": "testuser", + "text": "hello", + "ts": "1234567890.123456", + }, + } + body.update(envelope) + return json.dumps(body) + + +def _slash_body(params: dict[str, str]) -> str: + from urllib.parse import urlencode + + return urlencode(params) + + +def _interactive_body(payload: dict[str, Any]) -> str: + from urllib.parse import quote + + return f"payload={quote(json.dumps(payload))}" + + +def _block_actions_payload(**extra: Any) -> dict[str, Any]: + payload = { + "type": "block_actions", + "user": {"id": "U123", "username": "testuser", "name": "Test User"}, + "container": { + "type": "message", + "message_ts": "1234567890.123456", + "channel_id": "C_INTER", + }, + "channel": {"id": "C_INTER", "name": "general"}, + "message": {"ts": "1234567890.123456"}, + "actions": [{"type": "button", "action_id": "test_action", "value": "v"}], + } + payload.update(extra) + return payload + + +# --------------------------------------------------------------------------- +# event_callback resolution +# --------------------------------------------------------------------------- + + +class TestInstallationProviderEvents: + @pytest.mark.asyncio + async def test_uses_provider_for_token_resolution_in_event_callback(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-external-token", bot_user_id="U_BOT_EXT")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request(_event_body(team_id="T_EXTERNAL_1")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_EXTERNAL_1", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_when_is_enterprise_install_true(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-enterprise-token", bot_user_id="U_BOT_ENT")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request( + _event_body( + team_id="T_WORKSPACE_1", + enterprise_id="E_ENTERPRISE_1", + is_enterprise_install=True, + ) + ) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("E_ENTERPRISE_1", True) + + @pytest.mark.asyncio + async def test_uses_team_id_when_is_enterprise_install_false(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-team-token", bot_user_id="U_BOT_TEAM")) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request( + _event_body( + team_id="T_TEAM_ONLY", + enterprise_id="E_SHOULD_IGNORE", + is_enterprise_install=False, + ) + ) + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("T_TEAM_ONLY", False) + + @pytest.mark.asyncio + async def test_returns_200_ok_when_provider_returns_none(self): + provider = _make_provider(None) + adapter, _, _ = await _make_provider_adapter(provider) + + req = _make_signed_request(_event_body(team_id="T_UNKNOWN")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_UNKNOWN", False) + + @pytest.mark.asyncio + async def test_does_not_fall_back_to_state_when_provider_is_set(self): + provider = _make_provider(None) + adapter, chat, state = await _make_provider_adapter(provider) + + # Set an installation in state - should NOT be used + await adapter.set_installation( + "T_STATE_TEAM", + SlackInstallation(bot_token="xoxb-state-token", bot_user_id="U_BOT_STATE"), + ) + + req = _make_signed_request(_event_body(team_id="T_STATE_TEAM")) + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_STATE_TEAM", False) + # Event must not be processed because the provider returned None -- + # falling back to the state-stored token here would mean the provider + # is not authoritative. + chat.process_message.assert_not_called() + # The internal installation key must never have been read. + read_keys = [str(c.args[0]) for c in state.get.call_args_list] + assert not any("slack:installation" in k for k in read_keys) + + @pytest.mark.asyncio + async def test_provider_token_reaches_request_context(self): + """The resolved installation's token is what downstream API calls use.""" + provider = _make_provider(SlackInstallation(bot_token="xoxb-ctx-token", bot_user_id="U_BOT_CTX")) + adapter, chat, _ = await _make_provider_adapter(provider) + + seen_tokens: list[str] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + seen_tokens.append(adapter_arg._get_token()) + + chat.process_message = MagicMock(side_effect=capture) + + req = _make_signed_request(_event_body(team_id="T_CTX")) + await adapter.handle_webhook(req) + + assert seen_tokens == ["xoxb-ctx-token"] + + +# --------------------------------------------------------------------------- +# Slash commands +# --------------------------------------------------------------------------- + + +class TestInstallationProviderSlashCommands: + @pytest.mark.asyncio + async def test_uses_provider_for_slash_commands(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-slash-token", bot_user_id="U_BOT_SLASH")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _slash_body( + { + "command": "/test", + "text": "hello", + "team_id": "T_SLASH_TEAM", + "channel_id": "C_SLASH", + "user_id": "U_SLASHER", + "response_url": "https://hooks.slack.com/commands/xxx", + } + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("T_SLASH_TEAM", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_for_slash_commands_in_enterprise_grid(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-ent-slash-token", bot_user_id="U_BOT_ENT_SLASH")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _slash_body( + { + "command": "/test", + "text": "hello", + "team_id": "T_ENT_WORKSPACE", + "enterprise_id": "E_ENT_ORG", + "is_enterprise_install": "true", + "channel_id": "C_SLASH", + "user_id": "U_SLASHER", + "response_url": "https://hooks.slack.com/commands/xxx", + } + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + await adapter.handle_webhook(req) + + provider.get_installation.assert_called_once_with("E_ENT_ORG", True) + + +# --------------------------------------------------------------------------- +# Interactive payloads +# --------------------------------------------------------------------------- + + +class TestInstallationProviderInteractive: + @pytest.mark.asyncio + async def test_uses_provider_for_interactive_payloads(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-interactive-token", bot_user_id="U_BOT_INTER")) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _interactive_body(_block_actions_payload(team={"id": "T_INTER_PROVIDER"})) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("T_INTER_PROVIDER", False) + + @pytest.mark.asyncio + async def test_uses_enterprise_id_for_interactive_payloads_in_enterprise_grid(self): + provider = _make_provider( + SlackInstallation(bot_token="xoxb-ent-interactive-token", bot_user_id="U_BOT_ENT_INTER") + ) + adapter, _, _ = await _make_provider_adapter(provider) + + body = _interactive_body( + _block_actions_payload( + team={"id": "T_ENT_INTER_WORKSPACE"}, + enterprise={"id": "E_ENT_INTER_ORG"}, + is_enterprise_install=True, + ) + ) + req = _make_signed_request(body, content_type="application/x-www-form-urlencoded") + response = await adapter.handle_webhook(req) + + assert response["status"] == 200 + provider.get_installation.assert_called_once_with("E_ENT_INTER_ORG", True) + + +# --------------------------------------------------------------------------- +# rehydrate_attachment +# --------------------------------------------------------------------------- + + +class TestInstallationProviderRehydrate: + @pytest.mark.asyncio + async def test_rehydrate_attachment_uses_provider_for_token_resolution(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-rehydrate-token", bot_user_id="U_BOT_REHYDRATE")) + adapter, _, _ = await _make_provider_adapter(provider) + adapter._fetch_slack_file = AsyncMock(return_value=b"\x00" * 8) # type: ignore[method-assign] + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_REHYDRATE", + }, + ) + ) + + assert rehydrated.fetch_data is not None + await rehydrated.fetch_data() + + provider.get_installation.assert_called_once_with("T_REHYDRATE", False) + adapter._fetch_slack_file.assert_awaited_once_with("https://files.slack.com/img.png", "xoxb-rehydrate-token") + + @pytest.mark.asyncio + async def test_rehydrate_attachment_uses_enterprise_id_when_enterprise_install(self): + provider = _make_provider( + SlackInstallation(bot_token="xoxb-ent-rehydrate-token", bot_user_id="U_BOT_ENT_REHYDRATE") + ) + adapter, _, _ = await _make_provider_adapter(provider) + adapter._fetch_slack_file = AsyncMock(return_value=b"\x00" * 8) # type: ignore[method-assign] + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_WORKSPACE", + "enterpriseId": "E_ORG", + "isEnterpriseInstall": "true", + }, + ) + ) + + assert rehydrated.fetch_data is not None + await rehydrated.fetch_data() + + provider.get_installation.assert_called_once_with("E_ORG", True) + adapter._fetch_slack_file.assert_awaited_once_with( + "https://files.slack.com/img.png", "xoxb-ent-rehydrate-token" + ) + + @pytest.mark.asyncio + async def test_rehydrate_attachment_raises_when_provider_returns_none(self): + provider = _make_provider(None) + adapter, _, _ = await _make_provider_adapter(provider) + + rehydrated = adapter.rehydrate_attachment( + Attachment( + type="image", + url="https://files.slack.com/img.png", + fetch_metadata={ + "url": "https://files.slack.com/img.png", + "teamId": "T_MISSING", + }, + ) + ) + + assert rehydrated.fetch_data is not None + with pytest.raises(AuthenticationError, match="Installation not found for team T_MISSING"): + await rehydrated.fetch_data() + provider.get_installation.assert_called_once_with("T_MISSING", False) + + +# --------------------------------------------------------------------------- +# Enterprise Grid metadata capture on attachments +# --------------------------------------------------------------------------- + + +class TestEnterpriseGridAttachmentMetadata: + @pytest.mark.asyncio + async def test_event_callback_with_file_captures_enterprise_grid_metadata(self): + provider = _make_provider(SlackInstallation(bot_token="xoxb-ent-event-token", bot_user_id="U_BOT_ENT_EVENT")) + adapter, chat, _ = await _make_provider_adapter(provider) + + # Invoke the factory while still inside the request-context frame so + # _create_attachment can read the per-request enterprise context. The + # task is created during process_message, before the isolated context + # exits, so the contextvars snapshot carries the enterprise fields. + captured: list[asyncio.Task[Any]] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + captured.append(asyncio.get_running_loop().create_task(factory())) + + chat.process_message = MagicMock(side_effect=capture) + + body = json.dumps( + { + "type": "event_callback", + "team_id": "T_ENT_WORKSPACE", + "enterprise_id": "E_ENT_FILE_ORG", + "is_enterprise_install": True, + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + # username present so the parse skips the user-lookup API call + "username": "testuser", + "text": "with file", + "ts": "1234567890.123456", + "files": [ + { + "id": "F1", + "mimetype": "image/png", + "url_private": "https://files.slack.com/captured.png", + "name": "captured.png", + } + ], + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + + assert len(captured) == 1 + message = await captured[0] + attachment = message.attachments[0] + assert attachment.fetch_metadata == { + "url": "https://files.slack.com/captured.png", + "teamId": "T_ENT_WORKSPACE", + "enterpriseId": "E_ENT_FILE_ORG", + "isEnterpriseInstall": "true", + } + + @pytest.mark.asyncio + async def test_non_enterprise_event_omits_enterprise_grid_metadata_keys(self): + """Plain workspace installs must not serialize enterprise keys + (omit, not ``None``/``"false"`` -- hazard #7).""" + provider = _make_provider(SlackInstallation(bot_token="xoxb-plain-token", bot_user_id="U_BOT_PLAIN")) + adapter, chat, _ = await _make_provider_adapter(provider) + + captured: list[asyncio.Task[Any]] = [] + + def capture(adapter_arg: Any, thread_id: str, factory: Any, options: Any = None) -> None: + captured.append(asyncio.get_running_loop().create_task(factory())) + + chat.process_message = MagicMock(side_effect=capture) + + body = json.dumps( + { + "type": "event_callback", + "team_id": "T_PLAIN", + "event": { + "type": "message", + "channel": "C_TEST", + "user": "U_USER", + "username": "testuser", + "text": "with file", + "ts": "1234567890.123456", + "files": [ + { + "id": "F2", + "mimetype": "image/png", + "url_private": "https://files.slack.com/plain.png", + "name": "plain.png", + } + ], + }, + } + ) + req = _make_signed_request(body) + await adapter.handle_webhook(req) + + assert len(captured) == 1 + message = await captured[0] + attachment = message.attachments[0] + assert attachment.fetch_metadata == { + "url": "https://files.slack.com/plain.png", + "teamId": "T_PLAIN", + } diff --git a/tests/test_teams_native_streaming.py b/tests/test_teams_native_streaming.py index 6f023a46..e51ee157 100644 --- a/tests/test_teams_native_streaming.py +++ b/tests/test_teams_native_streaming.py @@ -422,7 +422,7 @@ async def test_flush_failure_propagates_and_cancels_session(self): the text via the flush), but a flush failure means buffered text was never accepted by Teams. Swallowing it would let ``Thread.stream`` record the buffered text in ``SentMessage`` / - ``_message_history`` even though the user never saw it. Re-raise + ``_thread_history`` even though the user never saw it. Re-raise so the outer ``Thread.stream`` short-circuits the history append. """ adapter = _make_adapter(clock_step_ms=0.0) @@ -1413,7 +1413,7 @@ async def test_markdown_text_chunk_dataclass_extract_text(self): unconditionally on the happy path) would record empty text, and ``Thread.stream`` would override its own correct accumulator with the empty adapter view. Result: ``SentMessage`` and - ``_message_history`` both record an empty string while the user + ``_thread_history`` both record an empty string while the user sees nothing in Teams. """ from chat_sdk.types import MarkdownTextChunk, PlanUpdateChunk, TaskUpdateChunk diff --git a/tests/test_telegram_adapter.py b/tests/test_telegram_adapter.py index 69f10ea7..e5c8993a 100644 --- a/tests/test_telegram_adapter.py +++ b/tests/test_telegram_adapter.py @@ -364,7 +364,7 @@ def test_adapter_class_properties(self): adapter = _make_adapter() assert adapter.name == "telegram" assert adapter.lock_scope == "channel" - assert adapter.persist_message_history is True + assert adapter.persist_thread_history is True assert adapter.bot_user_id is None # not yet initialized assert adapter.is_polling is False assert adapter.runtime_mode == "webhook" diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 13f72451..846a0ffe 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -18,6 +18,8 @@ import pytest +from chat_sdk.callback_url import decode_callback_value +from chat_sdk.cards import Actions, Button, Card from chat_sdk.channel import derive_channel_id from chat_sdk.errors import ChatNotImplementedError from chat_sdk.plan import StreamingPlan, StreamingPlanOptions @@ -32,6 +34,7 @@ from chat_sdk.thread import ThreadImpl, _ThreadImplConfig from chat_sdk.types import ( Author, + EphemeralMessage, FetchResult, MarkdownTextChunk, Message, @@ -39,6 +42,7 @@ PlanUpdateChunk, PostableMarkdown, PostableRaw, + PostEphemeralOptions, RawMessage, ScheduledMessage, TaskUpdateChunk, @@ -3755,3 +3759,156 @@ def test_should_convert_jsx_card_elements_to_cardelement_before_passing_to_adapt def test_should_convert_card_jsx_with_children_to_cardelement(self): assert True + + +# =========================================================================== +# callbackUrl processing (vercel/chat#454) +# =========================================================================== + + +def _make_card_with_callback(callback_url: str = "https://example.com/hook") -> Any: + return Card( + title="Test", + children=[ + Actions([Button(id="approve", label="Approve", callback_url=callback_url)]), + ], + ) + + +class TestCallbackUrlProcessing: + """describe("callbackUrl processing") from thread.test.ts""" + + FUTURE_DATE = datetime(2030, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def _callback_keys(self, state: MockStateAdapter) -> list[str]: + return [k for k in state.cache if k.startswith("chat:callback:")] + + # it("should encode callbackUrl when posting a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + await thread.post(_make_card_with_callback("https://example.com/post-hook")) + + posted_card = adapter._post_calls[0][1] + button = posted_card["children"][0]["children"][0] + + callback_token = decode_callback_value(button["value"]).callback_token + assert callback_token is not None + assert "callback_url" not in button + + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/post-hook" + + # it("should encode callbackUrl when posting via postEphemeral with native support") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_posting_via_postephemeral_with_native_support(self): + adapter = create_mock_adapter() + state = create_mock_state() + mock_post_ephemeral = AsyncMock( + return_value=EphemeralMessage( + id="ephemeral-1", + thread_id="slack:C123:1234.5678", + used_fallback=False, + raw={}, + ) + ) + adapter.post_ephemeral = mock_post_ephemeral # type: ignore[attr-defined] + thread = _make_thread(adapter, state) + + await thread.post_ephemeral( + "U456", + _make_card_with_callback("https://example.com/eph"), + PostEphemeralOptions(fallback_to_dm=False), + ) + + sent_card = mock_post_ephemeral.call_args[0][2] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/eph" + + # it("should encode callbackUrl when scheduling a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_scheduling_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + adapter.schedule_message = AsyncMock( # type: ignore[attr-defined] + return_value=ScheduledMessage( + scheduled_message_id="Q1", + channel_id="C123", + post_at=self.FUTURE_DATE, + raw={}, + _cancel=AsyncMock(return_value=None), + ) + ) + thread = _make_thread(adapter, state) + + await thread.schedule( + _make_card_with_callback("https://example.com/sched"), + post_at=self.FUTURE_DATE, + ) + + sent_card = adapter.schedule_message.call_args[0][1] + button = sent_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/sched" + + # it("should encode callbackUrl when editing a sent message with a card") + @pytest.mark.asyncio + async def test_should_encode_callbackurl_when_editing_a_sent_message_with_a_card(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + sent = await thread.post("Hello") + await sent.edit(_make_card_with_callback("https://example.com/edit")) + + edited_card = adapter._edit_calls[0][2] + button = edited_card["children"][0]["children"][0] + callback_token = decode_callback_value(button["value"]).callback_token + + assert callback_token is not None + stored = await state.get(f"chat:callback:{callback_token}") + assert stored is not None + assert stored["url"] == "https://example.com/edit" + + # it("should pass plain string posts through unchanged") + @pytest.mark.asyncio + async def test_should_pass_plain_string_posts_through_unchanged(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + await thread.post("Just text") + + assert adapter._post_calls == [("slack:C123:1234.5678", "Just text")] + assert self._callback_keys(state) == [] + + # it("should leave cards without callback buttons untouched") + @pytest.mark.asyncio + async def test_should_leave_cards_without_callback_buttons_untouched(self): + adapter = create_mock_adapter() + state = create_mock_state() + thread = _make_thread(adapter, state) + + card = Card( + title="Plain", + children=[Actions([Button(id="ok", label="OK", value="keep")])], + ) + await thread.post(card) + + posted_card = adapter._post_calls[0][1] + # No state writes for callback storage + assert self._callback_keys(state) == [] + assert posted_card["children"][0]["children"][0]["value"] == "keep" diff --git a/tests/test_message_history.py b/tests/test_thread_history.py similarity index 72% rename from tests/test_message_history.py rename to tests/test_thread_history.py index 44d12f51..8861aebd 100644 --- a/tests/test_message_history.py +++ b/tests/test_thread_history.py @@ -1,6 +1,7 @@ -"""Tests for MessageHistoryCache: append, get, TTL, and limit behavior. +"""Tests for ThreadHistoryCache: append, get, TTL, and limit behavior. -Port of message-history related tests from the Vercel Chat SDK. +Port of thread-history related tests from the Vercel Chat SDK +(thread-history.test.ts, renamed upstream from message-history.test.ts). """ from __future__ import annotations @@ -11,12 +12,12 @@ import pytest -from chat_sdk.message_history import ( +from chat_sdk.thread_history import ( DEFAULT_MAX_MESSAGES, DEFAULT_TTL_MS, KEY_PREFIX, - MessageHistoryCache, - MessageHistoryConfig, + ThreadHistoryCache, + ThreadHistoryConfig, ) from chat_sdk.types import Attachment, Author, Message, MessageMetadata @@ -89,17 +90,17 @@ async def _set(key: str, value: Any, *a: Any, **kw: Any) -> None: # --------------------------------------------------------------------------- -class TestMessageHistoryConstruction: +class TestThreadHistoryConstruction: def test_default_config(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) assert cache._max_messages == DEFAULT_MAX_MESSAGES assert cache._ttl_ms == DEFAULT_TTL_MS def test_custom_config(self): state = _make_mock_state() - config = MessageHistoryConfig(max_messages=50, ttl_ms=3600000) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(max_messages=50, ttl_ms=3600000) + cache = ThreadHistoryCache(state, config) assert cache._max_messages == 50 assert cache._ttl_ms == 3600000 @@ -110,31 +111,35 @@ def test_custom_config(self): class TestAppendAndGet: + # TS: "should use appendToList for atomic appends" @pytest.mark.asyncio - async def test_append_single_message(self): + async def test_should_use_appendtolist_for_atomic_appends(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) - msg = _make_message("Hello world") + msg = _make_message("Hello world", msg_id="m1") await cache.append("thread-1", msg) state.append_to_list.assert_called_once() - call_kwargs = state.append_to_list.call_args - key = call_kwargs[0][0] - assert key == f"{KEY_PREFIX}thread-1" + args, kwargs = state.append_to_list.call_args + assert args[0] == f"{KEY_PREFIX}thread-1" + assert args[1]["id"] == "m1" + assert kwargs == {"max_length": 100, "ttl_ms": 7 * 24 * 60 * 60 * 1000} + # TS: "should return empty array for unknown thread" @pytest.mark.asyncio - async def test_get_messages_empty(self): + async def test_should_return_empty_array_for_unknown_thread(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) messages = await cache.get_messages("nonexistent-thread") assert messages == [] + # TS: "should append and retrieve messages" @pytest.mark.asyncio - async def test_append_and_retrieve(self): + async def test_should_append_and_retrieve_messages(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) msg1 = _make_message("First", msg_id="m1") msg2 = _make_message("Second", msg_id="m2") @@ -150,10 +155,11 @@ async def test_append_and_retrieve(self): assert messages[1].text == "Second" assert messages[2].text == "Third" + # TS: "should strip raw field on storage" @pytest.mark.asyncio - async def test_serialized_raw_is_nulled(self): + async def test_should_strip_raw_field_on_storage(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) msg = _make_message("With raw") msg.raw = {"some": "data"} @@ -171,10 +177,11 @@ async def test_serialized_raw_is_nulled(self): class TestGetMessagesLimit: + # TS: "should support limit parameter in getMessages" @pytest.mark.asyncio - async def test_limit_returns_newest_n(self): + async def test_should_support_limit_parameter_in_getmessages(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) for i in range(5): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -187,7 +194,7 @@ async def test_limit_returns_newest_n(self): @pytest.mark.asyncio async def test_limit_larger_than_stored(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("thread-1", _make_message("only one")) messages = await cache.get_messages("thread-1", limit=100) @@ -196,7 +203,7 @@ async def test_limit_larger_than_stored(self): @pytest.mark.asyncio async def test_limit_none_returns_all(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) for i in range(10): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -211,11 +218,12 @@ async def test_limit_none_returns_all(self): class TestMaxMessagesTrimming: + # TS: "should trim to maxMessages, keeping newest" @pytest.mark.asyncio - async def test_trims_to_max_messages(self): + async def test_should_trim_to_maxmessages_keeping_newest(self): state = _make_mock_state() - config = MessageHistoryConfig(max_messages=3) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(max_messages=3) + cache = ThreadHistoryCache(state, config) for i in range(5): await cache.append("thread-1", _make_message(f"msg-{i}", msg_id=f"m{i}")) @@ -241,24 +249,14 @@ class TestTTLPropagation: @pytest.mark.asyncio async def test_passes_ttl_to_state(self): state = _make_mock_state() - config = MessageHistoryConfig(ttl_ms=86400000) - cache = MessageHistoryCache(state, config) + config = ThreadHistoryConfig(ttl_ms=86400000) + cache = ThreadHistoryCache(state, config) await cache.append("thread-1", _make_message("test")) call_kwargs = state.append_to_list.call_args[1] assert call_kwargs.get("ttl_ms") == 86400000 - @pytest.mark.asyncio - async def test_default_ttl_is_7_days(self): - state = _make_mock_state() - cache = MessageHistoryCache(state) - - await cache.append("thread-1", _make_message("test")) - - call_kwargs = state.append_to_list.call_args[1] - assert call_kwargs.get("ttl_ms") == 7 * 24 * 60 * 60 * 1000 - # --------------------------------------------------------------------------- # Thread isolation @@ -266,10 +264,11 @@ async def test_default_ttl_is_7_days(self): class TestThreadIsolation: + # TS: "should keep threads isolated" @pytest.mark.asyncio - async def test_messages_are_per_thread(self): + async def test_should_keep_threads_isolated(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("thread-A", _make_message("msg A", msg_id="mA")) await cache.append("thread-B", _make_message("msg B", msg_id="mB")) @@ -292,7 +291,7 @@ class TestKeyPrefix: @pytest.mark.asyncio async def test_uses_correct_key_prefix(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) await cache.append("my-thread", _make_message("test")) @@ -309,7 +308,7 @@ class TestMessageSerializationRoundtrip: @pytest.mark.asyncio async def test_message_survives_roundtrip(self): state = _make_mock_state() - cache = MessageHistoryCache(state) + cache = ThreadHistoryCache(state) original = _make_message("Round trip test", msg_id="m-rt", thread_id="t-rt") original.attachments = [ @@ -326,3 +325,34 @@ async def test_message_survives_roundtrip(self): assert len(restored.attachments) == 1 assert restored.attachments[0].type == "image" assert restored.attachments[0].name == "img.png" + + +# --------------------------------------------------------------------------- +# MessageHistoryCache (deprecated alias) +# --------------------------------------------------------------------------- + + +class TestMessageHistoryCacheDeprecatedAlias: + # TS: "re-exports ThreadHistoryCache under the old name" + @pytest.mark.asyncio + async def test_reexports_threadhistorycache_under_the_old_name(self): + from chat_sdk.message_history import ( + KEY_PREFIX as OLD_KEY_PREFIX, + ) + from chat_sdk.message_history import ( + MessageHistoryCache, + MessageHistoryConfig, + ) + + assert MessageHistoryCache is ThreadHistoryCache + assert MessageHistoryConfig is ThreadHistoryConfig + # The storage key prefix is deliberately unchanged — renaming it + # would silently orphan persisted data. + assert OLD_KEY_PREFIX == "msg-history:" + + state = _make_mock_state() + cache = MessageHistoryCache(state) + await cache.append("t-1", _make_message("hello", msg_id="m1")) + msgs = await cache.get_messages("t-1") + assert len(msgs) == 1 + assert msgs[0].text == "hello" diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py new file mode 100644 index 00000000..96bf0467 --- /dev/null +++ b/tests/test_transcripts.py @@ -0,0 +1,450 @@ +"""Faithful translation of transcripts.test.ts. + +Tests for TranscriptsApiImpl: append, list, count, delete, eviction, and +formatted round-trip behavior. + +TS file: packages/chat/src/transcripts.test.ts +""" + +from __future__ import annotations + +import asyncio +import re +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import pytest + +from chat_sdk.shared.markdown_parser import parse_markdown +from chat_sdk.testing import MockStateAdapter, create_mock_state, create_test_message +from chat_sdk.transcripts import TranscriptsApiImpl +from chat_sdk.types import ( + AppendInput, + AppendOptions, + CountQuery, + DeleteTarget, + ListQuery, + TranscriptsConfig, +) + +UUID_RE = re.compile(r"^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$") +USER_KEY_REQUIRED_RE = r"options\.user_key is required" +INVALID_DURATION_RE = r"Invalid duration" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@dataclass +class _ThreadStub: + """Minimal Postable stand-in (mirrors the TS test's cast stub).""" + + adapter: Any + id: str + + +def create_test_thread(adapter_name: str = "slack", thread_id: str = "slack:C123:1234.5678") -> _ThreadStub: + return _ThreadStub(adapter=SimpleNamespace(name=adapter_name), id=thread_id) + + +def _record_append_to_list_calls(state: MockStateAdapter) -> list[tuple[str, Any, int | None, int | None]]: + """Wrap ``state.append_to_list`` to record ``(key, value, max_length, ttl_ms)``. + + Python stand-in for the TS mock state's ``vi.fn()``-wrapped ``appendToList``. + """ + calls: list[tuple[str, Any, int | None, int | None]] = [] + real_append = state.append_to_list + + async def _recording_append( + key: str, value: Any, *, max_length: int | None = None, ttl_ms: int | None = None + ) -> None: + calls.append((key, value, max_length, ttl_ms)) + await real_append(key, value, max_length=max_length, ttl_ms=ttl_ms) + + state.append_to_list = _recording_append + return calls + + +@pytest.fixture +def state() -> MockStateAdapter: + return create_mock_state() + + +@pytest.fixture +def api(state: MockStateAdapter) -> TranscriptsApiImpl: + return TranscriptsApiImpl(state, TranscriptsConfig()) + + +async def _seed(api: TranscriptsApiImpl, user_key: str, count: int = 5) -> None: + thread = create_test_thread() + for i in range(count): + msg = create_test_message(f"m{i}", f"msg {i}") + msg.user_key = user_key + await api.append(thread, msg) + + +# --------------------------------------------------------------------------- +# append +# --------------------------------------------------------------------------- + + +class TestAppend: + # TS: "persists a Message under the resolved userKey" + async def test_persists_a_message_under_the_resolved_userkey(self, state, api): + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "user@example.com" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.user_key == "user@example.com" + assert stored.text == "Hello" + assert stored.role == "user" + assert stored.platform == "slack" + assert stored.thread_id == "slack:C123:1234.5678" + assert stored.platform_message_id == "m1" + assert UUID_RE.match(stored.id) + assert stored.timestamp > 0 + + assert len(append_calls) == 1 + key, value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:user@example.com" + assert value["userKey"] == "user@example.com" + assert max_length == 200 + assert ttl_ms is None + + # TS: "no-ops when Message has no userKey" + async def test_noops_when_message_has_no_userkey(self, state, api): + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + # user_key deliberately not set + + stored = await api.append(thread, msg) + + assert stored is None + assert append_calls == [] + + # TS: "requires options.userKey when appending an AppendInput" + async def test_requires_optionsuserkey_when_appending_an_appendinput(self, api): + thread = create_test_thread() + + with pytest.raises(ValueError, match=USER_KEY_REQUIRED_RE): + await api.append(thread, AppendInput(role="assistant", text="hi")) + + # TS: "appends an assistant message with explicit userKey" + async def test_appends_an_assistant_message_with_explicit_userkey(self, api): + thread = create_test_thread() + + stored = await api.append( + thread, + AppendInput(role="assistant", text="Hello, Mike"), + AppendOptions(user_key="mike@acme.com"), + ) + + assert stored is not None + assert stored.role == "assistant" + assert stored.user_key == "mike@acme.com" + assert stored.text == "Hello, Mike" + assert stored.platform_message_id is None + + # TS: "omits formatted by default" + async def test_omits_formatted_by_default(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.formatted is None + + # TS: "includes formatted when storeFormatted is true" + async def test_includes_formatted_when_storeformatted_is_true(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(store_formatted=True)) + thread = create_test_thread() + msg = create_test_message("m1", "**bold**") + msg.user_key = "u1" + + stored = await api.append(thread, msg) + + assert stored is not None + assert stored.formatted is not None + assert stored.formatted["type"] == "root" + + round_trip = await api.list(ListQuery(user_key="u1")) + assert round_trip[0].formatted == stored.formatted + + # TS: "passes retention duration string through as ttlMs" + async def test_passes_retention_duration_string_through_as_ttlms(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(retention="7d")) + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + await api.append(thread, msg) + + assert len(append_calls) == 1 + key, _value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:u1" + assert max_length == 200 + assert ttl_ms == 7 * 24 * 60 * 60 * 1000 + + # TS: "passes numeric retention through unchanged" + async def test_passes_numeric_retention_through_unchanged(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(retention=60_000, max_per_user=50)) + append_calls = _record_append_to_list_calls(state) + thread = create_test_thread() + msg = create_test_message("m1", "Hello") + msg.user_key = "u1" + + await api.append(thread, msg) + + assert len(append_calls) == 1 + key, _value, max_length, ttl_ms = append_calls[0] + assert key == "transcripts:user:u1" + assert max_length == 50 + assert ttl_ms == 60_000 + + # TS: "rejects malformed duration strings" + def test_rejects_malformed_duration_strings(self, state): + with pytest.raises(ValueError, match=INVALID_DURATION_RE): + TranscriptsApiImpl(state, TranscriptsConfig(retention="7days")) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +class TestList: + # TS: "returns all messages in chronological order by default" + async def test_returns_all_messages_in_chronological_order_by_default(self, api): + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1")) + + assert len(listed) == 5 + assert [m.text for m in listed] == ["msg 0", "msg 1", "msg 2", "msg 3", "msg 4"] + + # TS: "returns empty array when no messages exist" + async def test_returns_empty_array_when_no_messages_exist(self, api): + listed = await api.list(ListQuery(user_key="nobody")) + assert listed == [] + + # TS: "returns the newest N when limit is set, still chronological" + async def test_returns_the_newest_n_when_limit_is_set_still_chronological(self, api): + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1", limit=2)) + + assert len(listed) == 2 + assert [m.text for m in listed] == ["msg 3", "msg 4"] + + # TS: "filters by platform" + async def test_filters_by_platform(self, api): + slack_thread = create_test_thread("slack") + discord_thread = create_test_thread("discord", "discord:C:T") + slack_msg = create_test_message("s1", "from slack") + slack_msg.user_key = "u1" + discord_msg = create_test_message("d1", "from discord") + discord_msg.user_key = "u1" + + await api.append(slack_thread, slack_msg) + await api.append(discord_thread, discord_msg) + + slack_only = await api.list(ListQuery(user_key="u1", platforms=["slack"])) + assert len(slack_only) == 1 + assert slack_only[0].platform == "slack" + + # TS: "filters by threadId" + async def test_filters_by_threadid(self, api): + thread_a = create_test_thread("slack", "slack:C:A") + thread_b = create_test_thread("slack", "slack:C:B") + m1 = create_test_message("m1", "thread A") + m1.user_key = "u1" + m2 = create_test_message("m2", "thread B") + m2.user_key = "u1" + + await api.append(thread_a, m1) + await api.append(thread_b, m2) + + listed = await api.list(ListQuery(user_key="u1", thread_id="slack:C:A")) + assert len(listed) == 1 + assert listed[0].text == "thread A" + + # TS: "filters by role" + async def test_filters_by_role(self, api): + thread = create_test_thread() + user_msg = create_test_message("m1", "user msg") + user_msg.user_key = "u1" + await api.append(thread, user_msg) + await api.append( + thread, + AppendInput(role="assistant", text="bot msg"), + AppendOptions(user_key="u1"), + ) + + user_only = await api.list(ListQuery(user_key="u1", roles=["user"])) + assert len(user_only) == 1 + assert user_only[0].role == "user" + + assistant_only = await api.list(ListQuery(user_key="u1", roles=["assistant"])) + assert len(assistant_only) == 1 + assert assistant_only[0].role == "assistant" + + +# --------------------------------------------------------------------------- +# count +# --------------------------------------------------------------------------- + + +class TestCount: + # TS: "returns the total stored count for a userKey" + async def test_returns_the_total_stored_count_for_a_userkey(self, api): + await _seed(api, "u1", count=3) + + total = await api.count(CountQuery(user_key="u1")) + assert total == 3 + + # TS: "returns 0 for unknown userKey" + async def test_returns_0_for_unknown_userkey(self, api): + assert await api.count(CountQuery(user_key="nobody")) == 0 + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +class TestDelete: + # TS: "clears all stored entries for a userKey and reports the count" + async def test_clears_all_stored_entries_for_a_userkey_and_reports_the_count(self, api): + await _seed(api, "u1", count=4) + + result = await api.delete(DeleteTarget(user_key="u1")) + + assert result.deleted == 4 + assert await api.count(CountQuery(user_key="u1")) == 0 + assert await api.list(ListQuery(user_key="u1")) == [] + + # TS: "returns deleted: 0 for unknown userKey" + async def test_returns_deleted_0_for_unknown_userkey(self, api): + result = await api.delete(DeleteTarget(user_key="nobody")) + assert result.deleted == 0 + + # TS: "hides the tombstone from list/count after deletion" + async def test_hides_the_tombstone_from_listcount_after_deletion(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "before") + msg.user_key = "u1" + await api.append(thread, msg) + await api.delete(DeleteTarget(user_key="u1")) + + # list and count both ignore the tombstone marker + assert await api.list(ListQuery(user_key="u1")) == [] + assert await api.count(CountQuery(user_key="u1")) == 0 + + # TS: "appends after delete behave as if the list were freshly empty" + async def test_appends_after_delete_behave_as_if_the_list_were_freshly_empty(self, api): + thread = create_test_thread() + before = create_test_message("m1", "before") + before.user_key = "u1" + await api.append(thread, before) + await api.delete(DeleteTarget(user_key="u1")) + + after = create_test_message("m2", "after") + after.user_key = "u1" + await api.append(thread, after) + + listed = await api.list(ListQuery(user_key="u1")) + assert len(listed) == 1 + assert listed[0].text == "after" + assert await api.count(CountQuery(user_key="u1")) == 1 + + # TS: "does not double-count if delete is called twice" + async def test_does_not_doublecount_if_delete_is_called_twice(self, api): + thread = create_test_thread() + msg = create_test_message("m1", "hello") + msg.user_key = "u1" + await api.append(thread, msg) + + first = await api.delete(DeleteTarget(user_key="u1")) + second = await api.delete(DeleteTarget(user_key="u1")) + + assert first.deleted == 1 + assert second.deleted == 0 + + # TS: "preserves invariants when append/delete/append are interleaved without awaits" + async def test_preserves_invariants_when_appenddeleteappend_are_interleaved_without_awaits(self, api): + thread = create_test_thread() + before = create_test_message("m0", "before") + before.user_key = "u1" + await api.append(thread, before) + + post1 = create_test_message("m1", "post1") + post1.user_key = "u1" + post2 = create_test_message("m2", "post2") + post2.user_key = "u1" + + await asyncio.gather( + api.append(thread, post1), + api.delete(DeleteTarget(user_key="u1")), + api.append(thread, post2), + ) + + listed = await api.list(ListQuery(user_key="u1")) + count = await api.count(CountQuery(user_key="u1")) + + # count() and list() must agree — neither should leak the tombstone. + assert count == len(listed) + # Whatever survives is a real entry under the right userKey, and + # never the pre-delete entry (which delete() must have evicted). + for entry in listed: + assert entry.user_key == "u1" + assert entry.text != "before" + # Final size is bounded by the two post-delete appends. + assert count <= 2 + + +# --------------------------------------------------------------------------- +# maxPerUser eviction +# --------------------------------------------------------------------------- + + +class TestMaxPerUserEviction: + # TS: "trims to maxPerUser via appendToList semantics" + async def test_trims_to_maxperuser_via_appendtolist_semantics(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(max_per_user=3)) + await _seed(api, "u1") + + listed = await api.list(ListQuery(user_key="u1")) + assert len(listed) == 3 + assert [m.text for m in listed] == ["msg 2", "msg 3", "msg 4"] + + +# --------------------------------------------------------------------------- +# formatted round-trip +# --------------------------------------------------------------------------- + + +class TestFormattedRoundTrip: + # TS: "preserves mdast Root through state serialization" + async def test_preserves_mdast_root_through_state_serialization(self, state): + api = TranscriptsApiImpl(state, TranscriptsConfig(store_formatted=True)) + thread = create_test_thread() + original = parse_markdown("# Hello\n\n*world*") + msg = create_test_message("m1", "Hello world") + msg.user_key = "u1" + msg.formatted = original + + await api.append(thread, msg) + listed = await api.list(ListQuery(user_key="u1")) + + assert listed[0].formatted == original diff --git a/tests/test_transcripts_wiring.py b/tests/test_transcripts_wiring.py new file mode 100644 index 00000000..c542fee9 --- /dev/null +++ b/tests/test_transcripts_wiring.py @@ -0,0 +1,240 @@ +"""Faithful translation of transcripts-wiring.test.ts. + +Tests for the Chat-level Transcripts API wiring: the constructor guard +(``transcripts`` requires ``identity``), the ``chat.transcripts`` accessor, +and the identity-resolution dispatch hook that populates +``message.user_key`` before handlers run. + +TS file: packages/chat/src/transcripts-wiring.test.ts +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.chat import Chat +from chat_sdk.errors import ChatError +from chat_sdk.testing import ( + MockAdapter, + MockLogger, + MockStateAdapter, + create_mock_adapter, + create_mock_state, + create_test_message, +) +from chat_sdk.types import ChatConfig, TranscriptsConfig + +TRANSCRIPTS_NOT_CONFIGURED_RE = r"chat\.transcripts is not configured" +IDENTITY_REQUIRED_RE = r"requires ChatConfig\.identity" + +THREAD_ID = "slack:C123:1234.5678" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_chat( + adapter: MockAdapter, + state: MockStateAdapter, + **overrides: object, +) -> Chat: + return Chat( + ChatConfig( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=overrides.pop("logger", MockLogger()), + **overrides, # type: ignore[arg-type] + ) + ) + + +async def _dispatch_subscribed_message( + chat: Chat, + adapter: MockAdapter, + state: MockStateAdapter, + handler: AsyncMock, +): + """Initialize, register a subscribed handler, and dispatch one message.""" + await chat.webhooks["slack"]("request") + chat.on_subscribed_message(handler) + await state.subscribe(THREAD_ID) + + message = create_test_message("msg-1", "hello") + await chat.handle_incoming_message(adapter, THREAD_ID, message) + return message + + +@pytest.fixture +def mock_adapter() -> MockAdapter: + return create_mock_adapter("slack") + + +@pytest.fixture +def mock_state() -> MockStateAdapter: + return create_mock_state() + + +# --------------------------------------------------------------------------- +# Construction / accessor +# --------------------------------------------------------------------------- + + +class TestTranscriptsApiWiring: + # TS: "throws at construction when transcripts is set without identity" + def test_throws_at_construction_when_transcripts_is_set_without_identity(self, mock_adapter, mock_state): + with pytest.raises(ValueError, match=IDENTITY_REQUIRED_RE): + _make_chat(mock_adapter, mock_state, transcripts=TranscriptsConfig()) + + # TS: "does not throw when neither transcripts nor identity is set" + def test_does_not_throw_when_neither_transcripts_nor_identity_is_set(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state) + assert isinstance(chat, Chat) + + # TS: "does not throw when identity is set without transcripts" + def test_does_not_throw_when_identity_is_set_without_transcripts(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state, identity=lambda ctx: "u1") + assert isinstance(chat, Chat) + + # TS: "chat.transcripts getter throws when transcripts was not configured" + def test_chattranscripts_getter_throws_when_transcripts_was_not_configured(self, mock_adapter, mock_state): + chat = _make_chat(mock_adapter, mock_state) + + with pytest.raises(ChatError, match=TRANSCRIPTS_NOT_CONFIGURED_RE): + _ = chat.transcripts + + # TS: "chat.transcripts returns the API instance when configured" + def test_chattranscripts_returns_the_api_instance_when_configured(self, mock_adapter, mock_state): + chat = _make_chat( + mock_adapter, + mock_state, + identity=lambda ctx: "u1", + transcripts=TranscriptsConfig(), + ) + + api = chat.transcripts + assert api is not None + assert callable(api.append) + assert callable(api.list) + assert callable(api.count) + assert callable(api.delete) + + +# --------------------------------------------------------------------------- +# Dispatch hook +# --------------------------------------------------------------------------- + + +class TestDispatchHook: + # TS: "populates message.userKey from the resolver before handlers run" + async def test_populates_messageuserkey_from_the_resolver_before_handlers_run(self, mock_adapter, mock_state): + identity = AsyncMock(return_value="user@example.com") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + identity.assert_called_once() + context = identity.call_args.args[0] + assert context.adapter == "slack" + assert context.author is message.author + assert context.message is message + handler.assert_called() + assert message.user_key == "user@example.com" + + # TS: "populates message.userKey from a sync resolver that returns a plain string" + async def test_populates_messageuserkey_from_a_sync_resolver_that_returns_a_plain_string( + self, mock_adapter, mock_state + ): + # The resolver contract allows plain (non-async) callables; MagicMock + # is deliberate here — its return value is used directly, without + # being awaited. + identity = MagicMock(return_value="sync-user@example.com") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + identity.assert_called_once() + handler.assert_called() + assert message.user_key == "sync-user@example.com" + + # TS: "leaves userKey undefined when the resolver returns null" + async def test_leaves_userkey_undefined_when_the_resolver_returns_null(self, mock_adapter, mock_state): + identity = AsyncMock(return_value=None) + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None + + # TS: "treats resolver returning empty string as no userKey" + async def test_treats_resolver_returning_empty_string_as_no_userkey(self, mock_adapter, mock_state): + identity = AsyncMock(return_value="") + handler = AsyncMock(return_value=None) + + chat = _make_chat( + mock_adapter, + mock_state, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None + + # TS: "logs and proceeds without userKey when the resolver throws" + async def test_logs_and_proceeds_without_userkey_when_the_resolver_throws(self, mock_adapter, mock_state): + identity = AsyncMock(side_effect=Exception("lookup failed")) + handler = AsyncMock(return_value=None) + logger = MockLogger() + + chat = _make_chat( + mock_adapter, + mock_state, + logger=logger, + identity=identity, + transcripts=TranscriptsConfig(), + ) + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + warn_calls = [call for call in logger.warn.calls if "Identity resolver threw" in call[0]] + assert len(warn_calls) == 1 + warn_context = warn_calls[0][1] + assert isinstance(warn_context["error"], Exception) + assert warn_context["adapter"] == "slack" + assert warn_context["thread_id"] == THREAD_ID + handler.assert_called() + assert message.user_key is None + + # TS: "does not call the resolver when no identity is configured" + async def test_does_not_call_the_resolver_when_no_identity_is_configured(self, mock_adapter, mock_state): + handler = AsyncMock(return_value=None) + chat = _make_chat(mock_adapter, mock_state) + + message = await _dispatch_subscribed_message(chat, mock_adapter, mock_state, handler) + + handler.assert_called() + assert message.user_key is None diff --git a/tests/test_whatsapp_adapter.py b/tests/test_whatsapp_adapter.py index c04dc332..f3d08516 100644 --- a/tests/test_whatsapp_adapter.py +++ b/tests/test_whatsapp_adapter.py @@ -361,5 +361,5 @@ def test_adapter_properties(self): adapter = _make_adapter() assert adapter.name == "whatsapp" assert adapter.lock_scope == "channel" - assert adapter.persist_message_history is True + assert adapter.persist_thread_history is True assert adapter.bot_user_id is None # not yet initialized