diff --git a/.changeset/xchat-adapter.md b/.changeset/xchat-adapter.md new file mode 100644 index 00000000..4d721549 --- /dev/null +++ b/.changeset/xchat-adapter.md @@ -0,0 +1,17 @@ +--- +"@chat-adapter/xchat": minor +"chat": patch +"create-chat-sdk": patch +--- + +New XChat adapter for X's encrypted messaging. All cryptography is handled inside the adapter via `@xdevplatform/chat-xdk` (wasm) and all REST goes through the typed `@xdevplatform/xdk` client. Only a bot token and a Juicebox PIN are required — the bot's identity (user id and @handle) is resolved from `GET /2/users/me` at startup. + +- Encrypted send/receive in DMs and groups (webhook push + polling), signature verification on by default; undecryptable or unverified events are dropped +- Mention detection from structured mention entities, swipe-replies to the bot, and a plain-text `@handle` fallback; group replies sent as quoted replies +- `openDM(userId)` starts (or reuses) an encrypted 1:1, running a full key exchange when needed so the bot can message first +- Media both ways: inbound attachments with lazy download+decrypt, outbound encrypted uploads +- Edit and delete of the bot's own messages; the first edit of a fresh message is age-gated by `editSafetyDelayMs` (default 5000ms) so receiving clients have stored the original +- Reactions in and out, read receipts (`sendReadReceipts`, default on), typing keep-alive, configurable group welcome message +- Cards degrade to text with tappable URL/mention entities plus a URL preview attachment +- Requests carry a chat-sdk-xchat/ User-Agent product token so Chat SDK traffic is identifiable in X API request logs (a User-Agent set via apiHeaders takes precedence) +- Registered in the `chat/adapters` catalog and the `create-chat-sdk` CLI scaffold diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index 0a11d87b..9123f5f9 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -99,6 +99,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-x" }, + { + "name": "XChat", + "slug": "xchat", + "type": "platform", + "description": "Hold encrypted 1:1 and group conversations on XChat with all cryptography handled inside the adapter.", + "packageName": "@chat-adapter/xchat", + "icon": "x", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-xchat" + }, { "name": "Messenger", "slug": "messenger", diff --git a/apps/docs/content/adapters/official/meta.json b/apps/docs/content/adapters/official/meta.json index 925bddeb..405092d5 100644 --- a/apps/docs/content/adapters/official/meta.json +++ b/apps/docs/content/adapters/official/meta.json @@ -14,6 +14,7 @@ "twilio", "messenger", "x", + "xchat", "web", "---State---", "memory", diff --git a/apps/docs/content/adapters/official/og/xchat.png b/apps/docs/content/adapters/official/og/xchat.png new file mode 100644 index 00000000..ec5b054f Binary files /dev/null and b/apps/docs/content/adapters/official/og/xchat.png differ diff --git a/apps/docs/content/adapters/official/xchat.mdx b/apps/docs/content/adapters/official/xchat.mdx new file mode 100644 index 00000000..2f07a20e --- /dev/null +++ b/apps/docs/content/adapters/official/xchat.mdx @@ -0,0 +1,263 @@ +--- +title: XChat +description: XChat (encrypted messaging) adapter using the X API v2 chat endpoints and the X Activity API. +packageName: "@chat-adapter/xchat" +slug: xchat +type: platform +logo: x +tagline: Hold encrypted 1:1 and group conversations on XChat, with all cryptography handled inside the adapter. +beta: true +features: + postMessage: yes + editMessage: + status: partial + label: Own text messages, age-gated + deleteMessage: + status: partial + label: Own messages + fileUploads: yes + streaming: + status: partial + label: Age-gated edits + scheduledMessages: no + cardFormat: + status: partial + label: Plain text + URL preview + buttons: no + linkButtons: + status: partial + label: Tappable URLs + selectMenus: no + tables: + status: partial + label: ASCII + fields: + status: partial + label: Plain text + imagesInCards: no + modals: no + slashCommands: no + mentions: yes + addReactions: yes + removeReactions: yes + typingIndicator: yes + directMessages: yes + ephemeralMessages: no + customApiEndpoint: + status: partial + label: Media REST calls + fetchMessages: yes + fetchSingleMessage: no + fetchThreadInfo: yes + fetchChannelMessages: no + listThreads: no + fetchChannelInfo: no + postChannelMessage: no +--- + +## Install + + + +## Quick start + + +The adapter auto-detects `XCHAT_BOT_TOKEN`, `XCHAT_PIN`, and `X_CONSUMER_SECRET` from the environment. The bot's user id and @handle are resolved from `GET /2/users/me` at startup. + + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createXchatAdapter } from "@chat-adapter/xchat"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + xchat: createXchatAdapter(), + }, +}); + +bot.onDirectMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); + +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +X sends two kinds of webhook requests: a **CRC challenge** (GET) answered with an HMAC-SHA256 of the token keyed by your app's consumer secret, and **event delivery** (POST) verified by the adapter via the `x-twitter-webhooks-signature` header. Handle the CRC challenge at the route level; it needs no adapter state: + +```typescript title="app/api/webhooks/xchat/route.ts" lineNumbers +import { createHmac } from "node:crypto"; +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const crcToken = url.searchParams.get("crc_token"); + if (!crcToken) { + return new Response("Missing crc_token", { status: 400 }); + } + const hash = createHmac("sha256", process.env.X_CONSUMER_SECRET!) + .update(crcToken) + .digest("base64"); + return Response.json({ response_token: `sha256=${hash}` }); +} + +export async function POST(request: Request) { + return bot.webhooks.xchat(request); +} +``` + +## Configuration + + + +## Setup + +### 1. Register encryption keys + +The bot account needs registered public keys and a Juicebox-backed private-key store before it can participate in encrypted conversations: + +1. Generate keypairs with [`chat-xdk`](https://github.com/xdevplatform/chat-xdk) (`generateKeypairs()`). +2. Register them via `POST /2/users/{id}/public_keys`. +3. Store the private keys in Juicebox with a PIN (`chat.setup(pin)`). + +The adapter unlocks the keys at startup with the same PIN (`XCHAT_PIN`). Registration is a one-time step per account. + +### 2. Create a webhook + +Register a webhook URL so the [X Activity API](https://docs.x.com/x-api/activity/introduction) can deliver events: + +```bash +curl -X POST "https://api.x.com/2/webhooks" \ + -H "Authorization: Bearer $APP_BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://your-domain.com/api/webhooks/xchat"}' +``` + +X validates the URL with a CRC challenge, so the endpoint must be live before you create the webhook. Webhooks and activity subscriptions can also be managed in the [X Developer Portal](https://developer.x.com/en/portal/dashboard). + +### 3. Subscribe to chat events + +Create [activity subscriptions](https://docs.x.com/x-api/activity/create-x-activity-subscription) for the bot user with the bot's OAuth 2.0 user token: + +```bash +curl -X POST "https://api.x.com/2/activity/subscriptions" \ + -H "Authorization: Bearer $BOT_USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event_type": "chat.received", + "filter": {"user_id": "YOUR_BOT_USER_ID"}, + "tag": "bot-chat-received", + "webhook_id": "YOUR_WEBHOOK_ID" + }' +``` + +Subscribe to `chat.conversation_join` as well if you want the bot to post a welcome message when it is added to a group. + +## Environment variables + +```bash +XCHAT_BOT_TOKEN=xcbot_... # OAuth2 user token for the bot account +XCHAT_PIN=... # Juicebox PIN for key unlock +X_CONSUMER_SECRET=... # App secret (CRC + webhook signature verification) +X_BOT_USERNAME=... # Optional @handle override; resolved from /2/users/me otherwise +X_VERIFY_SIGNATURES=true # Optional; set false to accept unverifiable messages +``` + +## Advanced + +### Encryption + +Every XChat conversation is encrypted. The adapter handles the full crypto lifecycle transparently: conversation-key extraction and caching, message decryption and signature verification, and encryption + signing on send. Conversation keys arrive via `KeyChange` events and are cached per conversation and key version. Incoming message signatures are verified against participants' signing keys; with `verifySignatures: true` (the default), unverifiable messages are dropped. Media is encrypted separately from message text using streaming encryption. + +### Formatting + +XChat has no client-side markdown rendering — outgoing markdown appears literally as plain text. URLs and @mentions are detected in outgoing text and rendered as tappable links and mention pills, tables are rendered as ASCII code blocks, and cards degrade to text with URL/mention entities plus a URL preview attachment (the first `x.com/.../status/...` URL in outgoing text auto-attaches as a post card). + +### Edits, deletes, and streaming + +The adapter can edit and delete only the bot's own messages. The first edit of a fresh message is held until the message is `editSafetyDelayMs` old (default 5000ms), so receiving clients have stored the original before the edit arrives. Deletes are delete-for-all. Streaming works through message edits, but the age gate makes rapid token-by-token updates coarse. + +### Mention behavior in groups + +`bot.onNewMention(handler)` fires for group messages that mention the bot. A group message counts as a mention when its rich-text mention entities include the bot's @handle or user ID, when it is a swipe-reply to one of the bot's own messages, or when its plain text contains `@handle` (fallback when no entities are present). To reply to every group message instead, register a catch-all `bot.onNewMessage(/.+/, handler)`. + +### Open DM + +`openDM(userId)` reuses an existing conversation or runs a fresh key exchange so the bot can message first. It requires the recipient to have encrypted chat set up, and the server requires the recipient to trust the bot (e.g. follow it) before the first message is accepted. + +### Read receipts and typing + +A read receipt is sent for each delivered inbound message before handlers run unless `sendReadReceipts: false`. The typing indicator is re-sent every 3 seconds while a handler runs. + +### Thread ID format + +``` +xchat:{conversationId} +``` + +- 1:1 conversations: `xchat:1234567890-9876543210` (both participant IDs) +- Group conversations: `xchat:g123456789` (opaque `g`-prefixed ID) + +REST paths use the other participant's user ID for 1:1 conversations and the `g...` ID for groups; the adapter converts automatically. + +## Feature support + + diff --git a/packages/adapter-xchat/README.md b/packages/adapter-xchat/README.md new file mode 100644 index 00000000..2ef8bece --- /dev/null +++ b/packages/adapter-xchat/README.md @@ -0,0 +1,291 @@ +[![XChat adapter for Chat SDK](https://chat-sdk.dev/en/adapters/official/xchat/og)](https://chat-sdk.dev/adapters/official/xchat) + +# @chat-adapter/xchat + +> npm package: [`@chat-adapter/xchat`](https://www.npmjs.com/package/@chat-adapter/xchat) + +[![Agent Stack](https://img.shields.io/badge/Agent%20Stack-000?style=flat-square&logo=vercel&logoColor=FFF&labelColor=000&color=000)](https://vercel.com/kb/agent-stack) +[![MIT License](https://img.shields.io/badge/License-MIT-000?style=flat-square&logo=opensourceinitiative&logoColor=white&labelColor=000&color=000)](../../LICENSE) + +XChat (encrypted messaging) adapter for [Chat SDK](https://chat-sdk.dev), built on the [X API v2](https://docs.x.com/x-api/introduction) chat endpoints, the [X Activity API](https://docs.x.com/x-api/activity/introduction) for event delivery, and [`@xdevplatform/chat-xdk`](https://github.com/xdevplatform/chat-xdk) for encryption. + +Every XChat conversation is encrypted. The adapter handles the full crypto lifecycle transparently: conversation-key extraction and caching, message decryption and signature verification, and encryption + signing on send. + +Documentation: [chat-sdk.dev/adapters/official/xchat](https://chat-sdk.dev/adapters/official/xchat) · Guides: [vercel.com/kb/chat-sdk](https://vercel.com/kb/chat-sdk) + +## Installation + +```bash +pnpm add @chat-adapter/xchat +``` + +## Scaffold with the CLI + +To scaffold a new Chat SDK bot and add this adapter: + +```bash +npx create-chat-sdk@latest my-bot +``` + +Visit the [adapters directory](https://chat-sdk.dev/adapters) to see other available official and vendor-official adapters. + +## Usage + +```typescript +import { Chat } from "chat"; +import { createXchatAdapter } from "@chat-adapter/xchat"; +import { createMemoryState } from "@chat-adapter/state-memory"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + xchat: createXchatAdapter(), + }, + state: createMemoryState(), +}); + +// DMs always +bot.onDirectMessage(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); + +// Group chats when the bot is @mentioned +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +Wire the webhook route (e.g. `app/api/webhooks/xchat/route.ts` in Next.js): + +```typescript +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + return bot.webhooks.xchat(request); +} + +export async function POST(request: Request) { + return bot.webhooks.xchat(request); +} +``` + +When using `createXchatAdapter()` without arguments, credentials are auto-detected from environment variables. The bot's @handle is resolved from `GET /2/users/me` at startup, so mention detection works without any hardcoding. + +## X Chat setup + +### 1. Register encryption keys + +The bot account needs registered public keys and a Juicebox-backed private-key store before it can participate in encrypted conversations: + +1. Generate keypairs with `chat-xdk` (`generateKeypairs()`) +2. Register them via `POST /2/users/{id}/public_keys` +3. Store the private keys in Juicebox with a PIN (`chat.setup(pin)`) + +The adapter unlocks the keys at startup with the same PIN (`XCHAT_PIN`). Registration is a one-time step per account — see the [chat-xdk documentation](https://github.com/xdevplatform/chat-xdk) for the full flow. + +### 2. Create a webhook + +Register a webhook URL so the X Activity API can deliver events ([docs](https://docs.x.com/x-api/webhooks/introduction)): + +```bash +curl -X POST "https://api.x.com/2/webhooks" \ + -H "Authorization: Bearer $APP_BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://your-domain.com/api/webhooks/xchat"}' +``` + +X validates the URL with a CRC challenge (see [Webhook route](#webhook-route) below), so the endpoint must be live before you create the webhook. + +Webhooks and activity subscriptions can also be created and managed in the [X Developer Portal](https://developer.x.com/en/portal/dashboard) instead of via the API. + +### 3. Subscribe to chat events + +Create [activity subscriptions](https://docs.x.com/x-api/activity/create-x-activity-subscription) for the bot user with the bot's OAuth2 user token: + +```bash +curl -X POST "https://api.x.com/2/activity/subscriptions" \ + -H "Authorization: Bearer $BOT_USER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event_type": "chat.received", + "filter": {"user_id": "YOUR_BOT_USER_ID"}, + "tag": "bot-chat-received", + "webhook_id": "YOUR_WEBHOOK_ID" + }' +``` + +Subscribe to `chat.conversation_join` as well if you want the bot to post a welcome message when it is added to a group. + +## Webhook route + +X sends two kinds of requests: + +1. **CRC challenge** (GET) — `?crc_token=...` must be answered with an HMAC-SHA256 of the token, keyed by your app's consumer secret. Handle this at the route level; it needs no adapter state. +2. **Event delivery** (POST) — chat events, verified by the adapter via the `x-twitter-webhooks-signature` header. + +```typescript +import { createHmac } from "node:crypto"; +import { bot } from "@/lib/bot"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const crcToken = url.searchParams.get("crc_token"); + if (!crcToken) { + return new Response("Missing crc_token", { status: 400 }); + } + const hash = createHmac("sha256", process.env.X_CONSUMER_SECRET!) + .update(crcToken) + .digest("base64"); + return Response.json({ response_token: `sha256=${hash}` }); +} + +export async function POST(request: Request) { + return bot.webhooks.xchat(request); +} +``` + +## Configuration + +All options are auto-detected from environment variables when not provided. + +| Option | Required | Description | +|--------|----------|-------------| +| `botToken` / `accessToken` | No* | OAuth2 user access token for the bot account. Auto-detected from `XCHAT_BOT_TOKEN` or `X_ACCESS_TOKEN` | +| `pin` | No | Juicebox PIN; when set, keys unlock automatically during `initialize()`. Auto-detected from `XCHAT_PIN` | +| `consumerSecret` | No | App consumer secret for webhook signature verification. Auto-detected from `X_CONSUMER_SECRET`. When unset, incoming webhook POSTs are **not** signature-verified (a warning is logged at startup) | +| `editSafetyDelayMs` | No | Minimum age (ms) a freshly posted message must reach before its first edit is sent, so receiving clients have stored the original the edit targets. Defaults to `5000`; `0` disables the wait | +| `sendReadReceipts` | No | Send a read receipt for each delivered inbound message before handlers run (default `true`) | +| `userName` | No | Bot @handle for mention detection. Auto-detected from `X_BOT_USERNAME`, otherwise resolved from `GET /2/users/me` | +| `welcomeMessage` | No | Message posted when the bot joins a group. `false` disables; omitted uses a default that explains @mention-to-reply | +| `verifySignatures` | No | Require verifiable signatures on incoming messages (default `true`). `X_VERIFY_SIGNATURES=false` opts out | +| `signingKeyVersion` | No | Signing key version override. Normally fetched during `initialize()`. Auto-detected from `X_SIGNING_KEY_VERSION` | +| `apiBaseUrl` | No | Base URL for media REST calls (defaults to `https://api.x.com`) | +| `apiHeaders` | No | Extra headers on every X API request. Unless it includes a `User-Agent`, the adapter prepends `chat-sdk-xchat/` to the client's default so Chat SDK traffic is identifiable | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +*Required at runtime — either via config or environment variable. + +## Environment variables + +```bash +XCHAT_BOT_TOKEN=xcbot_... # OAuth2 user token for the bot account +XCHAT_PIN=... # Juicebox PIN for key unlock +X_CONSUMER_SECRET=... # App secret (CRC + webhook signature verification) +X_BOT_USERNAME=... # Optional @handle override; resolved from /2/users/me otherwise +X_VERIFY_SIGNATURES=true # Optional; set false to accept unverifiable messages +``` + +## Features + +### Messaging + +| Feature | Supported | +|---------|-----------| +| Post message | Yes (encrypted + signed) | +| Group replies | Yes (quoted reply to the triggering message) | +| Edit message | Yes (encrypted edit event targeting the original's sequence id; only the bot's own text messages). The first edit of a fresh message is held until the message is `editSafetyDelayMs` old, so receiving clients have stored the original before the edit arrives | +| Delete message | Yes (delete-for-all: a locally signed delete action removes the message for every participant; only the bot's own messages) | +| Streaming | Limited (message edits work, but the first edit is age-gated by `editSafetyDelayMs`, so rapid token-by-token updates are coarse) | +| Read receipts | Yes (sent for each delivered inbound message unless `sendReadReceipts: false`; falls back to the latest conversation event) | +| TTL propagation | Yes (replies inherit the inbound message's disappearing-message TTL) | + +### Rich content + +| Feature | Supported | +|---------|-----------| +| Markdown | No client-side rendering — outgoing markdown appears literally as plain text (tables are the exception, see below) | +| URL / @mention entities | Yes (detected in outgoing text, rendered as tappable links and mention pills) | +| Post cards | Yes (first `x.com/.../status/...` URL in outgoing text auto-attaches) | +| Media out | Yes (files encrypt-streamed and uploaded via the 3-step media upload flow) | +| Media in | Yes (attachments exposed with lazy download + decrypt via `fetchData()`) | +| Cards / buttons / modals | No (X Chat has no interactive message surface) | +| Tables | ASCII code blocks | + +### Conversations + +| Feature | Supported | +|---------|-----------| +| Mentions | Yes (structured mention entities, swipe-reply-to-bot, plain-text `@handle` fallback) | +| Add / remove reactions | Yes | +| Incoming reactions | Yes (routed to `onReaction`) | +| Typing indicator | Yes (keep-alive re-sent every 3s while a handler runs) | +| DMs | Yes | +| Group chats | Yes (`g`-prefixed conversation IDs) | +| Group join welcome | Yes (configurable via `welcomeMessage`) | +| Open DM | Yes (`openDM(userId)` reuses an existing conversation or runs a fresh key exchange; requires the recipient to have encrypted chat set up) | + +### Message history + +| Feature | Supported | +|---------|-----------| +| Fetch messages | Yes (`GET /2/chat/conversations/{id}/events`, batch-decrypted) | +| Fetch thread info | Yes | +| List threads | No | + +## Mention behavior in groups + +`chat.onNewMention(handler)` fires for group messages that mention the bot. A group message counts as a mention when: + +1. Its rich-text **mention entities** include the bot's @handle or user ID — the authoritative signal, or +2. It is a **swipe-reply** to one of the bot's own messages, or +3. Its plain text contains `@handle` (fallback when no entities are present). + +To reply to every group message instead, register a catch-all `chat.onNewMessage(/.+/, handler)`. + +## Thread ID format + +``` +xchat:{conversationId} +``` + +- 1:1 conversations: `xchat:1234567890-9876543210` (both participant IDs) +- Group conversations: `xchat:g123456789` (opaque `g`-prefixed ID) + +REST paths use the other participant's user ID for 1:1 conversations and the `g...` ID for groups; the adapter converts automatically. + +## Encryption notes + +- Conversation keys arrive via `KeyChange` events and are cached per conversation and key version. When a key is missing at send time, the adapter fetches recent conversation events to extract one. +- Incoming message signatures are verified against participants' signing keys (fetched from `GET /2/users/{id}/public_keys` and cached). With `verifySignatures: true` (the default), unverifiable messages are dropped. +- Media is encrypted separately from message text using streaming encryption; the message carries a `media_hash_key` reference that the recipient uses to download and decrypt the blob. + +## Troubleshooting + +### CRC validation failing + +- Confirm `X_CONSUMER_SECRET` matches your app's consumer secret in the developer portal +- The response must be `{"response_token": "sha256="}` for GET requests + +### Events not arriving + +- Verify the subscription exists: `GET /2/activity/subscriptions` with the app bearer token +- Confirm the subscription's `webhook_id` points at a webhook whose URL is your live endpoint +- Subscription changes can take a few minutes to provision + +### "Invalid signature" on webhook POSTs + +- The adapter verifies `x-twitter-webhooks-signature` with HMAC-SHA256 keyed by `X_CONSUMER_SECRET` — make sure it is the same app that owns the webhook + +### Messages decrypt but the bot never replies in groups + +- Group replies require a mention — check that the sender actually @mentioned the bot's handle (the one from `/2/users/me`), or use `{ allGroupMessages: true }` + +### "No conversation key" when posting + +- The bot can only send in conversations where a conversation key has been shared with it (i.e., someone messaged the bot first, added it to the group, or the bot opened the conversation itself via `openDM(userId)`). `openDM` requires the recipient to have encrypted chat set up, and the server requires the recipient to trust the bot (e.g. follow it) before the first message is accepted. + +## AI Coding Agents + +If you use an AI coding agent such as OpenAI Codex, Claude Code, or Cursor, install the Chat SDK skill so it knows the SDK APIs, adapter patterns, and project conventions before writing code. + +```bash +npx skills add vercel/chat +``` + +The skill references bundled documentation in `node_modules/chat/docs`, plus adapter guides and starter templates in the published package. + +For agent-readable documentation, see [chat-sdk.dev/llms.txt](https://chat-sdk.dev/llms.txt) (page index) or [chat-sdk.dev/llms-full.txt](https://chat-sdk.dev/llms-full.txt) (full text). + +## License + +MIT diff --git a/packages/adapter-xchat/package.json b/packages/adapter-xchat/package.json new file mode 100644 index 00000000..4c9bf17e --- /dev/null +++ b/packages/adapter-xchat/package.json @@ -0,0 +1,67 @@ +{ + "name": "@chat-adapter/xchat", + "version": "0.1.0", + "description": "XChat adapter for Chat SDK: hold encrypted 1:1 and group conversations on X with all cryptography handled inside the adapter", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "@xdevplatform/chat-xdk": "^0.4.3", + "@xdevplatform/xdk": "^0.6.6", + "chat": "workspace:*", + "juicebox-sdk": "^0.3.6" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-xchat" + }, + "homepage": "https://chat-sdk.dev/adapters/official/xchat", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "chat-sdk", + "chatbot", + "xchat", + "x", + "x-api", + "bot", + "adapter", + "messaging", + "encryption", + "ai-agent", + "ai-sdk", + "vercel" + ], + "license": "MIT" +} diff --git a/packages/adapter-xchat/src/cards.test.ts b/packages/adapter-xchat/src/cards.test.ts new file mode 100644 index 00000000..d8400fde --- /dev/null +++ b/packages/adapter-xchat/src/cards.test.ts @@ -0,0 +1,146 @@ +import type { CardElement } from "chat"; +import { describe, expect, it } from "vitest"; +import { cardToXChat } from "./cards"; + +function card(overrides: Partial): CardElement { + return { type: "card", children: [], ...overrides }; +} + +describe("cardToXChat", () => { + it("renders title unwrapped and body text", () => { + const result = cardToXChat( + card({ + title: "Deploy ready", + subtitle: "build 42", + children: [{ type: "text", content: "All checks passed." }], + }) + ); + + expect(result.text).toBe("Deploy ready\nbuild 42\nAll checks passed."); + expect(result.urlCard).toBeUndefined(); + }); + + it("appends action link-buttons as label: url lines and uses the first as the preview card", () => { + const result = cardToXChat( + card({ + title: "Deploy ready", + children: [ + { type: "text", content: "All checks passed." }, + { + type: "actions", + children: [ + { + type: "link-button", + label: "View logs", + url: "https://ci.example.com/logs", + }, + { + type: "link-button", + label: "Dashboard", + url: "https://ci.example.com/dash", + }, + ], + }, + ], + }) + ); + + expect(result.text).toBe( + [ + "Deploy ready", + "All checks passed.", + "View logs: https://ci.example.com/logs", + "Dashboard: https://ci.example.com/dash", + ].join("\n") + ); + expect(result.urlCard).toEqual({ + url: "https://ci.example.com/logs", + displayTitle: "Deploy ready", + imageUrl: undefined, + }); + }); + + it("keeps inline links in the body without duplicating them as appended lines", () => { + const result = cardToXChat( + card({ + children: [ + { type: "link", label: "Docs", url: "https://docs.example.com" }, + ], + }) + ); + + // The shared fallback renders inline links as `label (url)` — no extra line. + expect(result.text).toBe("Docs (https://docs.example.com)"); + // Without a card title, the link label titles the preview card. + expect(result.urlCard).toEqual({ + url: "https://docs.example.com", + displayTitle: "Docs", + imageUrl: undefined, + }); + }); + + it("collects links nested inside sections", () => { + const result = cardToXChat( + card({ + children: [ + { + type: "section", + children: [ + { type: "link", label: "Docs", url: "https://docs.example.com" }, + ], + }, + ], + }) + ); + + expect(result.text).toBe("Docs (https://docs.example.com)"); + expect(result.urlCard?.url).toBe("https://docs.example.com"); + }); + + it("omits callback buttons entirely (no XChat representation)", () => { + const result = cardToXChat( + card({ + title: "Confirm", + children: [ + { + type: "actions", + children: [ + { type: "button", id: "approve", label: "Approve" }, + { type: "button", id: "reject", label: "Reject" }, + ], + }, + ], + }) + ); + + expect(result.text).toBe("Confirm"); + expect(result.urlCard).toBeUndefined(); + }); + + it("uses the card imageUrl as the preview banner", () => { + const result = cardToXChat( + card({ + title: "Release notes", + imageUrl: "https://cdn.example.com/banner.png", + children: [ + { type: "link", label: "Read", url: "https://example.com/notes" }, + ], + }) + ); + + expect(result.urlCard?.imageUrl).toBe("https://cdn.example.com/banner.png"); + }); + + it("falls back to the first image child for the preview banner", () => { + const result = cardToXChat( + card({ + children: [ + { type: "image", url: "https://cdn.example.com/chart.png" }, + { type: "link", label: "Report", url: "https://example.com/report" }, + ], + }) + ); + + expect(result.urlCard?.imageUrl).toBe("https://cdn.example.com/chart.png"); + }); +}); diff --git a/packages/adapter-xchat/src/cards.ts b/packages/adapter-xchat/src/cards.ts new file mode 100644 index 00000000..aea0b4d5 --- /dev/null +++ b/packages/adapter-xchat/src/cards.ts @@ -0,0 +1,134 @@ +/** + * Card rendering for the XChat adapter. + * + * XChat has no interactive card primitives (no buttons, keyboards, or + * callbacks), so cards degrade to what the wire protocol carries: + * + * - text with tappable rich-text entities (urls, mentions) + * - one URL preview card per message (`attachment_type: "url"`), optionally + * with an encrypted banner image + * + * Link buttons and inline links become `label: url` lines so they stay + * usable as entities. Callback buttons have no representation and are + * omitted, matching the shared fallback-text behavior. + */ + +import { cardToFallbackText } from "@chat-adapter/shared"; +import type { CardChild, CardElement } from "chat"; + +/** A URL preview card derived from a CardElement. */ +export interface XchatUrlCardSpec { + /** Title shown on the preview card. */ + displayTitle?: string; + /** Image to encrypt and attach as the card banner. */ + imageUrl?: string; + url: string; +} + +export interface XchatCardResult { + text: string; + /** The card's primary link, when it has one. */ + urlCard?: XchatUrlCardSpec; +} + +interface CollectedLink { + label?: string; + url: string; +} + +function collectLinks(children: CardChild[], out: CollectedLink[]): void { + for (const child of children) { + if (child.type === "link") { + out.push({ label: child.label, url: child.url }); + } else if (child.type === "actions") { + for (const action of child.children) { + if (action.type === "link-button") { + out.push({ label: action.label, url: action.url }); + } + } + } else if (child.type === "section") { + collectLinks(child.children, out); + } + } +} + +function firstImageUrl(card: CardElement): string | undefined { + if (card.imageUrl) { + return card.imageUrl; + } + const stack: CardChild[] = [...card.children]; + while (stack.length > 0) { + const child = stack.shift(); + if (!child) { + continue; + } + if (child.type === "image") { + return child.url; + } + if (child.type === "section") { + stack.push(...child.children); + } + } + return undefined; +} + +/** + * Render a card to XChat message parts: plain text (links appended as + * `label: url` lines so URL entities make them tappable) and the primary + * link as a URL preview card spec. + */ +export function cardToXChat(card: CardElement): XchatCardResult { + const links: CollectedLink[] = []; + collectLinks(card.children, links); + + // XChat renders plain text, so unwrap the bold markers the shared + // fallback puts around the title. + let text = cardToFallbackText(card, { + boldFormat: "*", + lineBreak: "\n", + // The gchat fallback profile produces the plain-text output XChat needs. + platform: "gchat", + }); + if (card.title) { + text = text.replace(`*${card.title}*`, card.title); + } + + // The first link is the card's primary destination; the rest surface as + // extra lines (the shared fallback already renders inline `link` children, + // so only action-row link buttons need appending). + const buttonLinks: CollectedLink[] = []; + const seenInText = new Set(); + for (const child of card.children) { + if (child.type === "link") { + seenInText.add(child.url); + } + if (child.type === "section") { + for (const inner of child.children) { + if (inner.type === "link") { + seenInText.add(inner.url); + } + } + } + } + for (const link of links) { + if (!seenInText.has(link.url)) { + buttonLinks.push(link); + } + } + + const linkLines = buttonLinks.map((l) => + l.label ? `${l.label}: ${l.url}` : l.url + ); + const fullText = [text, ...linkLines].filter(Boolean).join("\n"); + + const primary = links[0]; + const urlCard: XchatUrlCardSpec | undefined = primary + ? { + url: primary.url, + displayTitle: card.title ?? primary.label, + imageUrl: firstImageUrl(card), + } + : undefined; + + return { text: fullText, urlCard }; +} diff --git a/packages/adapter-xchat/src/index.test.ts b/packages/adapter-xchat/src/index.test.ts new file mode 100644 index 00000000..368bd4ab --- /dev/null +++ b/packages/adapter-xchat/src/index.test.ts @@ -0,0 +1,2338 @@ +import type { ChatInstance } from "chat"; +import { describe, expect, it, vi } from "vitest"; + +const createChatMock = vi.hoisted(() => vi.fn()); +const getPublicKeyMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ + data: [ + { + publicKeyVersion: "1", + juiceboxConfig: { + keyStoreTokenMapJson: JSON.stringify({ + realms: [], + register_threshold: 0, + recover_threshold: 0, + }), + maxGuessCount: 20, + tokenMap: [ + { + key: "testrealm", + value: { address: "https://juicebox.test", token: "test-token" }, + }, + ], + }, + }, + ], + }) +); + +vi.mock("@xdevplatform/chat-xdk", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createChat: (...args: unknown[]) => createChatMock(...args), + }; +}); + +vi.mock("@xdevplatform/xdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Client: class MockClient { + users = { getPublicKey: getPublicKeyMock }; + chat = { + getConversationEvents: vi.fn(), + sendMessage: vi.fn(), + sendTypingIndicator: vi.fn(), + }; + headers: Headers; + constructor(config?: { headers?: Record }) { + // Mirror the real client: defaults first, config headers win. + this.headers = new Headers({ + "User-Agent": "xdk-typescript/0.0.0-test", + ...config?.headers, + }); + } + }, + }; +}); + +import { + conversationPathId, + dashConversationId, + detectEntities, + extractMediaEntries, + extractPostAttachments, + mentionHandlesFromEntities, + withChatSdkUserAgent, + XchatAdapter, +} from "./index"; +import { + b64ToBytes, + createInitializedTestAdapter, + createMockChatInstance, + createTestCryptoEngine, + loadVectors, + type MockXdkClient, + mockLogger, + TEST_CANONICAL_CONVERSATION_ID, + TEST_CONVERSATION_ID, + TEST_OTHER_USER_ID, + TEST_PIN, + TEST_THREAD_ID, + TEST_USER_ID, +} from "./test-utils"; +import type { XchatRawMessage } from "./types"; + +// ── Error-message patterns asserted in tests ──────────────────────── +const MISSING_ACCESS_TOKEN_RE = /accessToken|botToken/i; +const UNINITIALIZED_RE = /uninitialized/; +const NOT_INITIALIZED_RE = /not initialized/i; +const NO_CONVERSATION_KEY_RE = /No conversation key/; +const NO_REGISTERED_CHAT_KEYS_RE = /no registered chat keys/; +const NO_SEQUENCE_ID_RE = /No sequence id known/; +const NO_JUICEBOX_CONFIG_RE = /no Juicebox config/; +const HTTP_404_RE = /HTTP 404/; +const CHAT_SDK_UA_TOKEN_RE = /^chat-sdk-xchat\/\d+\.\d+\.\d+/; +const CHAT_SDK_THEN_XDK_UA_RE = + /^chat-sdk-xchat\/\d+\.\d+\.\d+ xdk-typescript\//; + +/** + * Create a minimal XchatAdapter for testing. + * Crypto and API calls are not initialized — only thread ID and parsing methods work. + */ +function createTestAdapter(): XchatAdapter { + return new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + userName: "test-bot", + logger: mockLogger, + }); +} + +describe("conversationPathId", () => { + it("returns the other participant for hyphen 1:1 ids", () => { + expect(conversationPathId(TEST_CONVERSATION_ID, TEST_USER_ID)).toBe( + TEST_OTHER_USER_ID + ); + }); + + it("returns the other participant for colon 1:1 ids", () => { + expect( + conversationPathId(TEST_CANONICAL_CONVERSATION_ID, TEST_USER_ID) + ).toBe(TEST_OTHER_USER_ID); + }); + + it("returns group ids unchanged", () => { + expect(conversationPathId("gABCDE", TEST_USER_ID)).toBe("gABCDE"); + }); +}); + +describe("dashConversationId", () => { + it("dash-joins colon 1:1 ids", () => { + expect(dashConversationId(TEST_CANONICAL_CONVERSATION_ID)).toBe( + TEST_CONVERSATION_ID + ); + }); + + it("keeps dash 1:1 ids unchanged", () => { + expect(dashConversationId(TEST_CONVERSATION_ID)).toBe(TEST_CONVERSATION_ID); + }); + + it("returns group ids unchanged", () => { + expect(dashConversationId("gABCDE")).toBe("gABCDE"); + }); +}); + +describe("encodeThreadId", () => { + it("should encode a 1:1 conversation ID", () => { + const adapter = createTestAdapter(); + const result = adapter.encodeThreadId({ conversationId: "12345-67890" }); + expect(result).toBe("xchat:12345-67890"); + }); + + it("should encode a group conversation ID", () => { + const adapter = createTestAdapter(); + const result = adapter.encodeThreadId({ conversationId: "gABCDE" }); + expect(result).toBe("xchat:gABCDE"); + }); +}); + +describe("decodeThreadId", () => { + it("should decode a valid 1:1 thread ID", () => { + const adapter = createTestAdapter(); + const result = adapter.decodeThreadId("xchat:12345-67890"); + expect(result).toEqual({ conversationId: "12345-67890" }); + }); + + it("should decode a valid group thread ID", () => { + const adapter = createTestAdapter(); + const result = adapter.decodeThreadId("xchat:gABCDE"); + expect(result).toEqual({ conversationId: "gABCDE" }); + }); + + it("should throw on invalid prefix", () => { + const adapter = createTestAdapter(); + expect(() => adapter.decodeThreadId("slack:C123:ts123")).toThrow( + "Invalid XChat thread ID" + ); + }); + + it("should throw on empty after prefix", () => { + const adapter = createTestAdapter(); + expect(() => adapter.decodeThreadId("xchat:")).toThrow( + "Invalid XChat thread ID format" + ); + }); +}); + +describe("isDM", () => { + it("should return true for 1:1 conversation", () => { + const adapter = createTestAdapter(); + expect(adapter.isDM("xchat:12345-67890")).toBe(true); + }); + + it("should return false for group conversation", () => { + const adapter = createTestAdapter(); + expect(adapter.isDM("xchat:gABCDE")).toBe(false); + }); +}); + +describe("channelIdFromThreadId", () => { + it("should return the same thread ID", () => { + const adapter = createTestAdapter(); + expect(adapter.channelIdFromThreadId("xchat:12345-67890")).toBe( + "xchat:12345-67890" + ); + }); +}); + +describe("parseMessage", () => { + it("should parse a decrypted text message", () => { + const adapter = createTestAdapter(); + const raw: XchatRawMessage = { + event: { + id: "msg-1", + conversationId: "12345-67890", + senderId: "67890", + encodedEvent: "base64data", + createdAtMsec: "1700000000000", + }, + decrypted: { + type: "message", + id: "msg-1", + senderId: "67890", + conversationId: "12345:67890", + createdAtMsec: 1700000000000, + content: { text: "Hello world", contentType: "Text" }, + verified: true, + }, + }; + + const message = adapter.parseMessage(raw); + expect(message.id).toBe("msg-1"); + expect(message.text).toBe("Hello world"); + expect(message.author.userId).toBe("67890"); + expect(message.author.isMe).toBe(false); + expect(message.threadId).toBe("xchat:12345-67890"); + }); + + it("should handle messages with no decrypted content", () => { + const adapter = createTestAdapter(); + const raw: XchatRawMessage = { + event: { + id: "msg-2", + conversationId: "12345-67890", + senderId: "67890", + encodedEvent: "base64data", + }, + decrypted: null, + }; + + const message = adapter.parseMessage(raw); + expect(message.id).toBe("msg-2"); + expect(message.text).toBe(""); + }); + + it("should detect self messages", () => { + const adapter = createTestAdapter(); + const raw: XchatRawMessage = { + event: { + id: "msg-3", + conversationId: "12345-67890", + senderId: "12345", + encodedEvent: "base64data", + }, + decrypted: { + type: "message", + senderId: "12345", + content: { text: "My message" }, + }, + }; + + const message = adapter.parseMessage(raw); + expect(message.author.isMe).toBe(true); + }); +}); + +describe("handleWebhook", () => { + // CRC challenges (GET) are handled at the route level, not by the adapter. + // The adapter only handles POST requests. + + it("should return 405 for GET requests", async () => { + const adapter = createTestAdapter(); + const request = new Request("https://example.com/webhook", { + method: "GET", + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(405); + }); + + it("should return 400 for invalid JSON body", async () => { + const adapter = createTestAdapter(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: "not json", + headers: { "Content-Type": "application/json" }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); + + it("should return 200 for valid event without conversationId", async () => { + const adapter = createTestAdapter(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("should reject POST with missing signature when consumerSecret is set", async () => { + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + consumerSecret: "test-secret", + userName: "test-bot", + logger: mockLogger, + }); + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "evt-1", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_OTHER_USER_ID, + encoded_event: "data", + }, + }, + }; + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { "Content-Type": "application/json" }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("should reject POST with invalid signature", async () => { + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + consumerSecret: "test-secret", + userName: "test-bot", + logger: mockLogger, + }); + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "evt-1", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_OTHER_USER_ID, + encoded_event: "data", + }, + }, + }; + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { + "Content-Type": "application/json", + "x-twitter-webhooks-signature": "sha256=invalidsignature", + }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("should accept POST with valid signature", async () => { + const { createHmac } = await import("node:crypto"); + const secret = "test-secret"; + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + consumerSecret: secret, + userName: "test-bot", + logger: mockLogger, + }); + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "evt-1", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_OTHER_USER_ID, + encoded_event: "data", + }, + }, + }; + const body = JSON.stringify(xaaPayload); + const sig = `sha256=${createHmac("sha256", secret).update(body).digest("base64")}`; + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + headers: { + "Content-Type": "application/json", + "x-twitter-webhooks-signature": sig, + }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + + it("should skip chat.sent events", async () => { + const adapter = createTestAdapter(); + const xaaPayload = { + data: { + event_type: "chat.sent", + payload: { + id: "evt-1", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_OTHER_USER_ID, + encoded_event: "data", + }, + }, + }; + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { "Content-Type": "application/json" }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); +}); + +describe("createXchatAdapter", () => { + it("should throw when accessToken is missing", async () => { + const { createXchatAdapter } = await import("./index"); + expect(() => createXchatAdapter({})).toThrow(MISSING_ACCESS_TOKEN_RE); + }); + + it("should create adapter with only a token (identity resolved at initialize)", async () => { + const { createXchatAdapter } = await import("./index"); + const adapter = createXchatAdapter({ + accessToken: "token", + }); + expect(adapter).toBeInstanceOf(XchatAdapter); + expect(adapter.name).toBe("xchat"); + expect(adapter.cryptoStatus).toBe("uninitialized"); + }); + + it("should accept botToken as accessToken alias", async () => { + const { createXchatAdapter } = await import("./index"); + const adapter = createXchatAdapter({ + botToken: "token", + }); + expect(adapter).toBeInstanceOf(XchatAdapter); + }); + + it("should accept optional signingKeyVersion override", async () => { + const { createXchatAdapter } = await import("./index"); + const adapter = createXchatAdapter({ + accessToken: "token", + userId: "12345", + signingKeyVersion: "42", + }); + expect(adapter).toBeInstanceOf(XchatAdapter); + }); + + it("should accept verifySignatures override", async () => { + const { createXchatAdapter } = await import("./index"); + const adapter = createXchatAdapter({ + accessToken: "token", + userId: "12345", + verifySignatures: false, + }); + expect(adapter).toBeInstanceOf(XchatAdapter); + }); + + it("should accept pin for Juicebox auto-unlock", async () => { + const { createXchatAdapter } = await import("./index"); + const adapter = createXchatAdapter({ + botToken: "token", + userId: "12345", + pin: "2580", + }); + expect(adapter).toBeInstanceOf(XchatAdapter); + }); +}); + +function juiceboxStubFromEngine( + engine: Awaited> +) { + return new Proxy(engine as object, { + get(target, prop, receiver) { + if (prop === "unlock" || prop === "setup") { + return async () => engine.getPublicKeys(); + } + return Reflect.get(target, prop, receiver); + }, + }); +} + +describe("verifySignatures", () => { + it("leaves chat-xdk default (reject unverified) when unset", async () => { + const engine = await createTestCryptoEngine(); + const setRejectSpy = vi.spyOn(engine, "setRejectUnverified"); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + getPublicKeyMock.mockClear(); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + expect(setRejectSpy).not.toHaveBeenCalled(); + expect(adapter.cryptoStatus).toBe("ready"); + } finally { + createChatMock.mockReset(); + } + }); + + it("opts out of verification when verifySignatures is false", async () => { + const engine = await createTestCryptoEngine(); + const setRejectSpy = vi.spyOn(engine, "setRejectUnverified"); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + verifySignatures: false, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + expect(setRejectSpy).toHaveBeenCalledWith(false); + } finally { + createChatMock.mockReset(); + } + }); +}); + +describe("unlock", () => { + it("should throw when called before initialize", async () => { + const adapter = createTestAdapter(); + await expect(adapter.unlock("1234")).rejects.toThrow(UNINITIALIZED_RE); + }); +}); + +// ============================================================================= +// Integration tests — real chat-xdk WASM crypto, mocked HTTP +// ============================================================================= + +describe("initialize (Juicebox)", () => { + it("should auto-unlock when pin is provided", async () => { + const engine = await createTestCryptoEngine(); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + getPublicKeyMock.mockClear(); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + }); + expect(adapter.cryptoStatus).toBe("uninitialized"); + await adapter.initialize(mockChat as unknown as ChatInstance); + expect(adapter.cryptoStatus).toBe("ready"); + expect(createChatMock).toHaveBeenCalled(); + expect(getPublicKeyMock).toHaveBeenCalled(); + } finally { + createChatMock.mockReset(); + } + }); + + it("stamps the Chat SDK product token on the client User-Agent", async () => { + const engine = await createTestCryptoEngine(); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + const client = (adapter as any).xdkClient as { headers: Headers }; + expect(client.headers.get("user-agent")).toMatch(CHAT_SDK_THEN_XDK_UA_RE); + } finally { + createChatMock.mockReset(); + } + }); + + it("leaves a User-Agent supplied via apiHeaders untouched", async () => { + const engine = await createTestCryptoEngine(); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + apiHeaders: { "User-Agent": "my-bot/1.0" }, + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + const client = (adapter as any).xdkClient as { headers: Headers }; + expect(client.headers.get("user-agent")).toBe("my-bot/1.0"); + } finally { + createChatMock.mockReset(); + } + }); + + it("establishes the session identity on unlock", async () => { + const engine = await createTestCryptoEngine(); + const setIdentitySpy = vi.spyOn(engine, "setIdentity"); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + // The signing key version comes from the mocked getPublicKey response. + expect(setIdentitySpy).toHaveBeenCalledWith(TEST_USER_ID, "1"); + } finally { + createChatMock.mockReset(); + } + }); + + it("should stay locked when pin is omitted", async () => { + const engine = await createTestCryptoEngine(); + createChatMock.mockResolvedValue(juiceboxStubFromEngine(engine)); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + userName: "test-bot", + logger: mockLogger, + }); + await adapter.initialize(mockChat as unknown as ChatInstance); + expect(adapter.cryptoStatus).toBe("locked"); + } finally { + createChatMock.mockReset(); + } + }); + + it("should throw and set error status when no juicebox config", async () => { + createChatMock.mockReset(); + getPublicKeyMock.mockResolvedValueOnce({ + data: [{ publicKeyVersion: "1" }], + }); + + try { + const mockChat = createMockChatInstance(); + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + userName: "test-bot", + logger: mockLogger, + }); + await expect( + adapter.initialize(mockChat as unknown as ChatInstance) + ).rejects.toThrow(NO_JUICEBOX_CONFIG_RE); + expect(adapter.cryptoStatus).toBe("error"); + expect(createChatMock).not.toHaveBeenCalled(); + } finally { + getPublicKeyMock.mockReset(); + getPublicKeyMock.mockResolvedValue({ + data: [ + { + publicKeyVersion: "1", + juiceboxConfig: { + keyStoreTokenMapJson: JSON.stringify({ + realms: [], + register_threshold: 0, + recover_threshold: 0, + }), + maxGuessCount: 20, + tokenMap: [ + { + key: "testrealm", + value: { + address: "https://juicebox.test", + token: "test-token", + }, + }, + ], + }, + }, + ], + }); + } + }); +}); + +describe("postMessage (with real crypto)", () => { + it("should throw when adapter is not ready", async () => { + const adapter = createTestAdapter(); + await expect(adapter.postMessage(TEST_THREAD_ID, "Hello")).rejects.toThrow( + NOT_INITIALIZED_RE + ); + }); + + it("should throw when no conversation key available even after auto-fetch", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + + // Mock getConversationEvents to return no events + xdk.chat.getConversationEvents = vi.fn().mockResolvedValue({ + data: [], + meta: {}, + }); + + await expect( + adapter.postMessage(TEST_THREAD_ID, "Hello") + ).rejects.toThrow(NO_CONVERSATION_KEY_RE); + } finally { + restore(); + } + }); +}); + +describe("postMessage sends encrypted payload (real crypto)", () => { + it("should encrypt text and call sendMessage with encrypted payload", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + + // Inject a conversation key so postMessage can encrypt + const keysMap = (adapter as any).conversationKeys as Map; + const vectors = loadVectors(); + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + + // Also inject a conversation token + const tokensMap = (adapter as any).conversationTokens as Map< + string, + string + >; + tokensMap.set(TEST_CONVERSATION_ID, "conv-token-123"); + + // Mock sendMessage on the XDK client + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ + data: { id: "sent-msg-123" }, + }); + + // Send a message — this uses real chat-xdk WASM encryption + const result = await adapter.postMessage(TEST_THREAD_ID, "Got it!"); + + expect(result.id).toBeDefined(); + expect(result.raw.decrypted?.content?.text).toBe("Got it!"); + + // Verify sendMessage was called with encrypted content + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + const [convId, payload] = (xdk.chat.sendMessage as any).mock.calls[0]; + // 1:1 REST paths use the recipient user id, not the composite conversation id. + expect(convId).toBe(TEST_OTHER_USER_ID); + expect(payload.encodedMessageCreateEvent).toBeTruthy(); + expect(typeof payload.encodedMessageCreateEvent).toBe("string"); + expect(payload.encodedMessageEventSignature).toBeTruthy(); + expect(payload.messageId).toBeTruthy(); + expect(payload.conversationToken).toBe("conv-token-123"); + } finally { + restore(); + } + }); +}); + +describe("fetchMessages with mocked API (adapter logic)", () => { + it("should parse API response events into messages", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + + // Mock getConversationEvents to return raw events. + // Note: since we can't create real decryptable events in unit tests + // (encryptMessage output ≠ what the API returns in encoded_event), + // we test the adapter's parsing logic with events that fail decryption. + // The adapter should gracefully skip undecryptable events. + xdk.chat.getConversationEvents = vi.fn().mockResolvedValue({ + data: [ + { + id: "evt-1", + sequenceId: "seq-1", + senderId: TEST_OTHER_USER_ID, + conversationId: TEST_CONVERSATION_ID, + createdAtMsec: "1700000000000", + encodedEvent: "not-a-real-encrypted-event", + conversationToken: "conv-token-456", + }, + ], + meta: {}, + }); + + // Signing-key lookup hits the API — return no usable keys so decryption + // proceeds unverified and the bogus event is skipped. + xdk.users.getPublicKey = vi.fn().mockResolvedValue({ data: [] }); + + const result = await adapter.fetchMessages(TEST_THREAD_ID, { limit: 50 }); + + // The event should fail decryption gracefully → 0 messages + expect(result.messages.length).toBe(0); + + // But the conversation token should have been cached + const tokensMap = (adapter as any).conversationTokens as Map< + string, + string + >; + expect(tokensMap.get(TEST_CONVERSATION_ID)).toBe("conv-token-456"); + + // Verify getConversationEvents was called with the recipient user id + expect(xdk.chat.getConversationEvents).toHaveBeenCalledOnce(); + expect(xdk.chat.getConversationEvents).toHaveBeenCalledWith( + TEST_OTHER_USER_ID, + expect.objectContaining({ maxResults: 50 }) + ); + } finally { + restore(); + } + }); +}); + +describe("handleWebhook full flow (real crypto)", () => { + it("should route a decryptable XAA chat.received event to handleIncomingMessage", async () => { + const { adapter, mockChat, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const vectors = loadVectors(); + const xdk = getXdkClient(); + + // Serve the fixture sender's signing keys so the event signature + // verifies for real. + xdk.users.getPublicKey = vi.fn().mockResolvedValue({ + data: [ + { + publicKeyVersion: vectors.event_signing_key_version, + signingPublicKey: vectors.signing_public_b64, + publicKey: vectors.identity_public_b64, + identityPublicKeySignature: + vectors.identity_public_key_signature_b64, + }, + ], + }); + + // Cache the conversation key the fixture event was encrypted with. + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(vectors.event_conversation_id, { + keys: { + [vectors.event_conversation_key_version]: b64ToBytes( + vectors.conversation_key_b64 + ), + }, + latestVersion: vectors.event_conversation_key_version, + }); + + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "webhook-evt-1", + conversation_id: vectors.event_conversation_id, + sender_id: vectors.event_sender_id, + encoded_event: vectors.event_message_b64, + conversation_key_version: vectors.event_conversation_key_version, + }, + }, + }; + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { "Content-Type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + // The webhook decrypts in the background, then hands the parsed + // message to handleIncomingMessage. + await vi.waitFor(() => { + expect(mockChat.handleIncomingMessage).toHaveBeenCalledOnce(); + }); + const [, threadId, message] = ( + mockChat.handleIncomingMessage as ReturnType + ).mock.calls[0]; + expect(threadId).toBe(`xchat:${vectors.event_conversation_id}`); + expect(message.text).toBe(vectors.event_message_text); + expect(message.author.userId).toBe(vectors.event_sender_id); + expect(message.author.isMe).toBe(false); + } finally { + restore(); + } + }); + + it("drops webhook events that cannot be decrypted", async () => { + const { adapter, mockChat, restore } = await createInitializedTestAdapter(); + try { + // No conversation key is cached, so decryption fails; the event must + // be dropped (same as the poll path), not delivered as an empty + // message. + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "webhook-evt-undecryptable", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_OTHER_USER_ID, + encoded_event: "some-encrypted-data", + conversation_key_version: "1", + }, + }, + }; + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { "Content-Type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + // Give the background decrypt task a chance to run. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(mockChat.handleIncomingMessage).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("should skip self-messages in webhook", async () => { + const { adapter, mockChat, restore } = await createInitializedTestAdapter(); + try { + const xaaPayload = { + data: { + event_type: "chat.received", + payload: { + id: "self-evt-1", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_USER_ID, // from self + encoded_event: "irrelevant", + }, + }, + }; + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(xaaPayload), + headers: { "Content-Type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + // Give any (incorrect) background task a chance to run + await new Promise((resolve) => setImmediate(resolve)); + expect(mockChat.handleIncomingMessage).not.toHaveBeenCalled(); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe("read receipts on delivery (real crypto)", () => { + /** Build the fixture chat.received webhook request and prime the adapter. */ + function primeFixtureDelivery( + adapter: XchatAdapter, + xdk: MockXdkClient + ): Request { + const vectors = loadVectors(); + + xdk.users.getPublicKey = vi.fn().mockResolvedValue({ + data: [ + { + publicKeyVersion: vectors.event_signing_key_version, + signingPublicKey: vectors.signing_public_b64, + publicKey: vectors.identity_public_b64, + identityPublicKeySignature: vectors.identity_public_key_signature_b64, + }, + ], + }); + // Fallback watermark for markAsRead when the event carries no sequence id. + xdk.chat.getConversationEvents = vi.fn().mockResolvedValue({ + data: [{ id: "evt-latest", sequenceId: "seq-42" }], + }); + + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(vectors.event_conversation_id, { + keys: { + [vectors.event_conversation_key_version]: b64ToBytes( + vectors.conversation_key_b64 + ), + }, + latestVersion: vectors.event_conversation_key_version, + }); + + return new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ + data: { + event_type: "chat.received", + payload: { + id: "webhook-evt-rr", + conversation_id: vectors.event_conversation_id, + sender_id: vectors.event_sender_id, + encoded_event: vectors.event_message_b64, + conversation_key_version: vectors.event_conversation_key_version, + }, + }, + }), + headers: { "Content-Type": "application/json" }, + }); + } + + it("sends a read receipt for each delivered message by default", async () => { + const { adapter, mockChat, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const request = primeFixtureDelivery(adapter, xdk); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + await vi.waitFor(() => { + expect(mockChat.handleIncomingMessage).toHaveBeenCalledOnce(); + }); + expect(xdk.chat.markConversationRead).toHaveBeenCalledOnce(); + const [, body] = xdk.chat.markConversationRead.mock.calls[0]; + expect(typeof body.seenUntilSequenceId).toBe("string"); + expect(body.seenUntilSequenceId.length).toBeGreaterThan(0); + } finally { + restore(); + } + }); + + it("suppresses read receipts when sendReadReceipts is false", async () => { + const { adapter, mockChat, getXdkClient, restore } = + await createInitializedTestAdapter({ sendReadReceipts: false }); + try { + const xdk = getXdkClient(); + const request = primeFixtureDelivery(adapter, xdk); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + await vi.waitFor(() => { + expect(mockChat.handleIncomingMessage).toHaveBeenCalledOnce(); + }); + expect(xdk.chat.markConversationRead).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe("withChatSdkUserAgent", () => { + it("prepends the product token to the xdk client's default", () => { + const ua = withChatSdkUserAgent("xdk-typescript/0.6.6"); + expect(ua).toMatch(CHAT_SDK_UA_TOKEN_RE); + expect(ua.endsWith(" xdk-typescript/0.6.6")).toBe(true); + }); + + it("stands alone when there is no existing User-Agent", () => { + expect(withChatSdkUserAgent(null)).toMatch(CHAT_SDK_UA_TOKEN_RE); + }); + + it("does not stack tokens when applied twice", () => { + const once = withChatSdkUserAgent("xdk-typescript/0.6.6"); + expect(withChatSdkUserAgent(once)).toBe(once); + }); +}); + +describe("detectEntities", () => { + it("detects URLs and mentions with correct spans", () => { + const text = "hey @alice check https://example.com/x now"; + const entities = detectEntities(text); + expect(entities).toEqual([ + [4, 10, "mention"], + [17, 38, "url"], + ]); + expect(text.slice(4, 10)).toBe("@alice"); + expect(text.slice(17, 38)).toBe("https://example.com/x"); + }); + + it("skips mentions inside detected URLs and emails", () => { + const entities = detectEntities("mail foo@bar.com or https://a.io/@baz"); + expect(entities?.every(([, , kind]) => kind === "url")).toBe(true); + }); + + it("returns null when nothing matches", () => { + expect(detectEntities("plain text only")).toBeNull(); + }); +}); + +describe("extractPostAttachments", () => { + it("extracts x.com post URLs as post cards, deduped by id", () => { + const text = + "see https://x.com/foo/status/123 and https://twitter.com/foo/status/123 and https://x.com/bar/status/456"; + expect(extractPostAttachments(text)).toEqual([ + { + attachment_type: "post", + rest_id: "123", + post_url: "https://x.com/foo/status/123", + }, + { + attachment_type: "post", + rest_id: "456", + post_url: "https://x.com/bar/status/456", + }, + ]); + }); + + it("returns null when no post URLs are present", () => { + expect(extractPostAttachments("no posts here")).toBeNull(); + }); +}); + +describe("mentionHandlesFromEntities", () => { + const text = "hey @Test_Bot and @other"; + + it("extracts handles from snake_case entity spans", () => { + const entities = [ + { start_index: 4, end_index: 13, content: { mention: {} } }, + { start_index: 18, end_index: 24, content: { mention: {} } }, + ]; + expect(mentionHandlesFromEntities(text, entities)).toEqual([ + "test_bot", + "other", + ]); + }); + + it("extracts handles from camelCase entity spans", () => { + const entities = [ + { startIndex: 4, endIndex: 13, content: { mention: {} } }, + ]; + expect(mentionHandlesFromEntities(text, entities)).toEqual(["test_bot"]); + }); + + it("ignores non-mention entities", () => { + const entities = [{ start_index: 4, end_index: 13, content: { url: {} } }]; + expect(mentionHandlesFromEntities(text, entities)).toEqual([]); + }); +}); + +describe("parseMessage mention + attachment mapping", () => { + function rawFor(decrypted: Record): XchatRawMessage { + return { + event: { + id: "evt-m1", + conversationId: "gGROUP1", + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + }, + decrypted: decrypted as XchatRawMessage["decrypted"], + }; + } + + it("sets isMention when an entity mention targets the bot handle", () => { + const adapter = createTestAdapter(); + const text = "yo @test-bot help"; + const message = adapter.parseMessage( + rawFor({ + type: "message", + senderId: TEST_OTHER_USER_ID, + content: { + contentType: "text", + text, + entities: [ + { start_index: 3, end_index: 12, content: { mention: {} } }, + ], + }, + }) + ); + expect(message.isMention).toBe(true); + }); + + it("sets isMention on a swipe-reply to the bot", () => { + const adapter = createTestAdapter(); + const message = adapter.parseMessage( + rawFor({ + type: "message", + senderId: TEST_OTHER_USER_ID, + content: { + contentType: "text", + text: "what about this?", + replyingToPreview: { sender_id: TEST_USER_ID, text: "earlier" }, + }, + }) + ); + expect(message.isMention).toBe(true); + }); + + it("ignores a swipe-reply whose preview failed validation", () => { + const adapter = createTestAdapter(); + const message = adapter.parseMessage( + rawFor({ + type: "message", + senderId: TEST_OTHER_USER_ID, + replyPreviewValidation: "invalid", + content: { + contentType: "text", + text: "what about this?", + replyingToPreview: { sender_id: TEST_USER_ID, text: "earlier" }, + }, + }) + ); + expect(message.isMention).toBeUndefined(); + }); + + it("leaves isMention unset for plain group text", () => { + const adapter = createTestAdapter(); + const message = adapter.parseMessage( + rawFor({ + type: "message", + senderId: TEST_OTHER_USER_ID, + content: { contentType: "text", text: "just chatting" }, + }) + ); + expect(message.isMention).toBeUndefined(); + }); + + it("maps media attachments with lazy fetchData", () => { + const adapter = createTestAdapter(); + const message = adapter.parseMessage( + rawFor({ + type: "message", + senderId: TEST_OTHER_USER_ID, + keyVersion: "1", + content: { contentType: "media", text: "" }, + attachments: [ + { + media: { + media_hash_key: "hash-abc", + type: "image", + filename: "pic.jpg", + filesize_bytes: 1234, + dimensions: { width: 640, height: 480 }, + }, + }, + ], + }) + ); + expect(message.attachments).toHaveLength(1); + const att = message.attachments[0]; + expect(att.type).toBe("image"); + expect(att.name).toBe("pic.jpg"); + expect(att.width).toBe(640); + expect(typeof att.fetchData).toBe("function"); + }); +}); + +describe("extractMediaEntries", () => { + it("collects media from attachments, content, and mediaHashes without dupes", () => { + const entries = extractMediaEntries({ + type: "message", + attachments: [ + { media: { media_hash_key: "h1", type: "image" } }, + { attachmentType: "media", mediaHashKey: "h2", mediaType: "video" }, + ], + content: { + attachments: [{ media: { media_hash_key: "h1", type: "image" } }], + }, + mediaHashes: [{ source: "gif_attachment", mediaHashKey: "h3" }], + }); + expect(entries.map((e) => e.hashKey)).toEqual(["h1", "h2", "h3"]); + expect(entries.map((e) => e.mediaType)).toEqual(["image", "video", "gif"]); + }); +}); + +describe("group quote-replies (real crypto)", () => { + it("replies with the raw inbound event (replyToEvent) in groups", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const groupConvId = "gGROUP1"; + const groupThreadId = `xchat:${groupConvId}`; + const vectors = loadVectors(); + const convKey = b64ToBytes(vectors.conversation_key_b64); + + // Cache the conversation key under the version the fixture raw event + // was encrypted with, so the SDK can decrypt the original to derive + // the reply preview. + const keyVersion = vectors.event_conversation_key_version; + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(groupConvId, { + keys: { [keyVersion]: convKey }, + latestVersion: keyVersion, + }); + + // Record inbound context by parsing a group message that carries the + // fixture's genuine server-shaped raw signed event. + adapter.parseMessage({ + event: { + id: "evt-g1", + conversationId: groupConvId, + senderId: TEST_OTHER_USER_ID, + encodedEvent: vectors.event_message_b64, + }, + decrypted: { + type: "message", + id: "msg-g1", + senderId: TEST_OTHER_USER_ID, + sequenceId: "seq-42", + content: { contentType: "text", text: vectors.event_message_text }, + }, + }); + + const engine = (adapter as any).cryptoEngine; + const replySpy = vi.spyOn(engine, "encryptReply"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + const result = await adapter.postMessage(groupThreadId, "quoted reply"); + + expect(replySpy).toHaveBeenCalledOnce(); + const params = replySpy.mock.calls[0][0] as Record; + expect(params.replyToEvent).toBe(vectors.event_message_b64); + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + // The message id chat-xdk generated for the payload reaches the API + // and the returned RawMessage unchanged + const payload = replySpy.mock.results[0].value as { messageId: string }; + const sent = xdk.chat.sendMessage.mock.calls[0][1]; + expect(payload.messageId).toBeTruthy(); + expect(sent.messageId).toBe(payload.messageId); + expect(result.id).toBe(payload.messageId); + } finally { + restore(); + } + }); + + it("quote-replies the newest inbound message even when older history is parsed after it", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const groupConvId = "gGROUP3"; + const groupThreadId = `xchat:${groupConvId}`; + const vectors = loadVectors(); + + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(groupConvId, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + + const parseAt = (id: string, createdAtMsec: number) => + adapter.parseMessage({ + event: { + id: `evt-${id}`, + conversationId: groupConvId, + senderId: TEST_OTHER_USER_ID, + encodedEvent: `raw-${id}`, + }, + decrypted: { + type: "message", + id, + senderId: TEST_OTHER_USER_ID, + sequenceId: `seq-${id}`, + createdAtMsec, + content: { contentType: "text", text: id }, + }, + }); + + // Webhook delivers the triggering message, then a history fetch parses + // the page newest-first, ending on the oldest message. + parseAt("newest", 3000); + parseAt("older", 2000); + parseAt("oldest", 1000); + + const engine = (adapter as any).cryptoEngine; + const replySpy = vi.spyOn(engine, "encryptReply"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + await adapter.postMessage(groupThreadId, "reply"); + + // Both encryptReply calls (event-based, then override fallback for the + // unparseable raw event) must target the newest message. + for (const call of replySpy.mock.calls) { + const params = call[0] as Record; + if (params.replyToEvent !== undefined) { + expect(params.replyToEvent).toBe("raw-newest"); + } else { + expect(params.replyToSequenceId).toBe("seq-newest"); + } + } + } finally { + restore(); + } + }); + + it("falls back to preview overrides when the raw event is unusable", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const groupConvId = "gGROUP2"; + const groupThreadId = `xchat:${groupConvId}`; + const vectors = loadVectors(); + + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(groupConvId, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + + // Inbound context with an encoded event that is not a parseable event + adapter.parseMessage({ + event: { + id: "evt-g2", + conversationId: groupConvId, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + }, + decrypted: { + type: "message", + id: "msg-g2", + senderId: TEST_OTHER_USER_ID, + sequenceId: "seq-42", + content: { contentType: "text", text: "original" }, + }, + }); + + const engine = (adapter as any).cryptoEngine; + const replySpy = vi.spyOn(engine, "encryptReply"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + await adapter.postMessage(groupThreadId, "quoted reply"); + + // First call with replyToEvent throws inside chat-xdk; the fallback + // retries with the explicit preview overrides. + expect(replySpy).toHaveBeenCalledTimes(2); + const params = replySpy.mock.calls[1][0] as Record; + expect(params.replyToEvent).toBeUndefined(); + expect(params.replyToSequenceId).toBe("seq-42"); + expect(params.replyToSenderId).toBe(TEST_OTHER_USER_ID); + expect(params.replyToText).toBe("original"); + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + } finally { + restore(); + } + }); + + it("uses encryptMessage for 1:1 conversations", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + + const engine = (adapter as any).cryptoEngine; + const replySpy = vi.spyOn(engine, "encryptReply"); + const messageSpy = vi.spyOn(engine, "encryptMessage"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + await adapter.postMessage(TEST_THREAD_ID, "dm reply"); + + expect(replySpy).not.toHaveBeenCalled(); + expect(messageSpy).toHaveBeenCalledOnce(); + } finally { + restore(); + } + }); +}); + +describe("reactions (real crypto)", () => { + it("encrypts and sends a reaction for a cached sequence id", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + // Cache the message → sequence mapping via parseMessage + adapter.parseMessage({ + event: { + id: "evt-r1", + conversationId: TEST_CONVERSATION_ID, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + }, + decrypted: { + type: "message", + id: "msg-r1", + senderId: TEST_OTHER_USER_ID, + sequenceId: "seq-7", + content: { contentType: "text", text: "react to me" }, + }, + }); + + const engine = (adapter as any).cryptoEngine; + const reactSpy = vi.spyOn(engine, "encryptAddReaction"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + await adapter.addReaction(TEST_THREAD_ID, "msg-r1", "👍"); + + expect(reactSpy).toHaveBeenCalledOnce(); + const params = reactSpy.mock.calls[0][0] as Record; + expect(params.targetMessageSequenceId).toBe("seq-7"); + // The API receives the message id chat-xdk generated for the payload + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + const payload = reactSpy.mock.results[0].value as { messageId: string }; + const sent = xdk.chat.sendMessage.mock.calls[0][1]; + expect(sent.messageId).toBe(payload.messageId); + expect(sent.encodedMessageCreateEvent).toBe(payload.encryptedContent); + } finally { + restore(); + } + }); + + it("skips reactions without a cached sequence id", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + xdk.chat.sendMessage = vi.fn(); + await adapter.addReaction(TEST_THREAD_ID, "unknown-msg", "👍"); + expect(xdk.chat.sendMessage).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe("inbound reaction routing", () => { + const reactionRaw = (targetSequenceId: string): XchatRawMessage => ({ + event: { + id: "evt-react", + conversationId: TEST_CONVERSATION_ID, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "y", + }, + decrypted: { + type: "message", + id: "react-1", + senderId: TEST_OTHER_USER_ID, + content: { + contentType: "reaction", + emoji: "👍", + targetMessageId: targetSequenceId, + }, + }, + }); + + it("resolves the reaction's target sequence id back to the message id", async () => { + const { adapter, mockChat, restore } = await createInitializedTestAdapter(); + try { + // Seed the messageId ⇄ sequenceId mapping via a parsed message. + adapter.parseMessage({ + event: { + id: "evt-r2", + conversationId: TEST_CONVERSATION_ID, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + }, + decrypted: { + type: "message", + id: "msg-r2", + senderId: TEST_OTHER_USER_ID, + sequenceId: "seq-9", + content: { contentType: "text", text: "react to me" }, + }, + }); + + const parsed = adapter.parseMessage(reactionRaw("seq-9")); + (adapter as any).routeReaction(TEST_THREAD_ID, parsed, true, undefined); + + expect(mockChat.processReaction).toHaveBeenCalledOnce(); + const [event] = (mockChat.processReaction as ReturnType) + .mock.calls[0]; + expect(event.messageId).toBe("msg-r2"); + expect(event.added).toBe(true); + expect(event.rawEmoji).toBe("👍"); + } finally { + restore(); + } + }); + + it("falls back to the raw sequence id when the target is unknown", async () => { + const { adapter, mockChat, restore } = await createInitializedTestAdapter(); + try { + const parsed = adapter.parseMessage(reactionRaw("seq-unseen")); + (adapter as any).routeReaction(TEST_THREAD_ID, parsed, false, undefined); + + expect(mockChat.processReaction).toHaveBeenCalledOnce(); + const [event] = (mockChat.processReaction as ReturnType) + .mock.calls[0]; + expect(event.messageId).toBe("seq-unseen"); + expect(event.added).toBe(false); + } finally { + restore(); + } + }); +}); + +describe("media upload/download via the XDK client (real crypto)", () => { + it("encrypt-streams and uploads outgoing files through the 3-step flow", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const convKey = b64ToBytes(vectors.conversation_key_b64); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": convKey }, + latestVersion: "1", + }); + + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + xdk.chat.mediaUploadInitialize.mockResolvedValue({ + data: { sessionId: "sess-1", mediaHashKey: "hash-up-1" }, + }); + xdk.chat.mediaUploadAppend.mockResolvedValue({ data: {} }); + xdk.chat.mediaUploadFinalize.mockResolvedValue({ data: {} }); + + const engine = (adapter as any).cryptoEngine; + const messageSpy = vi.spyOn(engine, "encryptMessage"); + const fileBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + + await adapter.postMessage(TEST_THREAD_ID, { + markdown: "here you go", + files: [ + { + filename: "pic.png", + data: fileBytes.buffer, + mimeType: "image/png", + }, + ], + }); + + // Initialize with the encrypted (not plaintext) size + expect(xdk.chat.mediaUploadInitialize).toHaveBeenCalledOnce(); + const initBody = xdk.chat.mediaUploadInitialize.mock.calls[0][0]; + expect(initBody.conversationId).toBe(TEST_CONVERSATION_ID); + expect(initBody.totalBytes).toBeGreaterThan(fileBytes.length); + + // One appended segment whose payload decrypts back to the file bytes + expect(xdk.chat.mediaUploadAppend).toHaveBeenCalledOnce(); + const [appendSession, appendBody] = + xdk.chat.mediaUploadAppend.mock.calls[0]; + expect(appendSession).toBe("sess-1"); + expect(appendBody.mediaHashKey).toBe("hash-up-1"); + expect(appendBody.segmentIndex).toBe(0); + const roundTrip = engine.decryptStream( + b64ToBytes(appendBody.media), + convKey + ); + expect(new Uint8Array(roundTrip)).toEqual(fileBytes); + + expect(xdk.chat.mediaUploadFinalize).toHaveBeenCalledWith("sess-1", { + conversationId: TEST_CONVERSATION_ID, + mediaHashKey: "hash-up-1", + numParts: "1", + }); + + // The encrypted message references the uploaded media + const params = messageSpy.mock.calls[0][0] as { + attachments: Record[]; + }; + expect(params.attachments[0].media_hash_key).toBe("hash-up-1"); + expect(params.attachments[0].filename).toBe("pic.png"); + } finally { + restore(); + } + }); + + it("downloads and decrypt-streams media attachments", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const convKey = b64ToBytes(vectors.conversation_key_b64); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": convKey }, + latestVersion: "1", + }); + + const engine = (adapter as any).cryptoEngine; + const plaintext = new Uint8Array([9, 8, 7, 6, 5]); + const encrypted = engine.encryptStream(plaintext, convKey); + xdk.chat.mediaDownload.mockResolvedValue( + encrypted.buffer.slice( + encrypted.byteOffset, + encrypted.byteOffset + encrypted.byteLength + ) + ); + + const result = await adapter.fetchMediaAttachment( + TEST_CONVERSATION_ID, + "hash-down-1", + "1" + ); + + // Media routes take the dash-joined participant pair, not the peer id + expect(xdk.chat.mediaDownload).toHaveBeenCalledWith( + TEST_CONVERSATION_ID, + "hash-down-1" + ); + expect(new Uint8Array(result)).toEqual(plaintext); + } finally { + restore(); + } + }); +}); + +describe("markAsRead fallback", () => { + it("falls back to the latest event sequence id when the message has none", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + xdk.chat.getConversationEvents = vi.fn().mockResolvedValue({ + data: [{ id: "evt-l", sequenceId: "seq-99" }], + }); + xdk.chat.markConversationRead = vi.fn().mockResolvedValue({ data: {} }); + + const message = adapter.parseMessage({ + event: { + id: "evt-nr", + conversationId: TEST_CONVERSATION_ID, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + }, + decrypted: null, + }); + await adapter.markAsRead(TEST_THREAD_ID, message); + + expect(xdk.chat.markConversationRead).toHaveBeenCalledWith( + TEST_OTHER_USER_ID, + { seenUntilSequenceId: "seq-99" } + ); + } finally { + restore(); + } + }); +}); + +describe("conversation_join welcome", () => { + it("posts the welcome message when added to a group", async () => { + const { adapter, restore } = await createInitializedTestAdapter(); + try { + const postSpy = vi + .spyOn(adapter, "postMessage") + .mockResolvedValue({ id: "w1", raw: {} as any, threadId: "t" }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ + data: { + event_type: "chat.conversation_join", + payload: { + id: "join-1", + conversation_id: "gNEWGROUP", + sender_id: TEST_USER_ID, + encoded_event: "x", + }, + }, + }), + headers: { "Content-Type": "application/json" }, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + await vi.waitFor(() => { + expect(postSpy).toHaveBeenCalledOnce(); + }); + const [threadId, welcome] = postSpy.mock.calls[0]; + expect(threadId).toBe("xchat:gNEWGROUP"); + expect(String(welcome)).toContain("@test-bot"); + } finally { + restore(); + } + }); + + it("does not post a welcome for 1:1 joins", async () => { + const { adapter, restore } = await createInitializedTestAdapter(); + try { + const postSpy = vi + .spyOn(adapter, "postMessage") + .mockResolvedValue({ id: "w1", raw: {} as any, threadId: "t" }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ + data: { + event_type: "chat.conversation_join", + payload: { + id: "join-2", + conversation_id: TEST_CONVERSATION_ID, + sender_id: TEST_USER_ID, + encoded_event: "x", + }, + }, + }), + headers: { "Content-Type": "application/json" }, + }); + await adapter.handleWebhook(request); + await new Promise((resolve) => setImmediate(resolve)); + expect(postSpy).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); + +describe("postMessage with a card (real crypto)", () => { + it("degrades the card to text + link entities + a url preview attachment", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + const engine = (adapter as any).cryptoEngine; + const messageSpy = vi.spyOn(engine, "encryptMessage"); + + await adapter.postMessage(TEST_THREAD_ID, { + card: { + type: "card", + title: "Deploy ready", + children: [ + { type: "text", content: "All checks passed." }, + { + type: "actions", + children: [ + { + type: "link-button", + label: "View logs", + url: "https://ci.example.com/logs", + }, + ], + }, + ], + }, + } as any); + + const params = messageSpy.mock.calls[0][0] as { + text: string; + entities: [number, number, string][] | null; + attachments: Record[] | null; + }; + expect(params.text).toBe( + "Deploy ready\nAll checks passed.\nView logs: https://ci.example.com/logs" + ); + // The appended link line becomes a tappable url entity + expect(params.entities?.some(([, , kind]) => kind === "url")).toBe(true); + // The primary link rides along as a URL preview card + expect(params.attachments).toEqual([ + { + attachment_type: "url", + url: "https://ci.example.com/logs", + display_title: "Deploy ready", + }, + ]); + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + } finally { + restore(); + } + }); +}); + +describe("urlCardAttachment banner upload (real crypto)", () => { + it("fetches, encrypts, and uploads the banner image", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + const originalFetch = globalThis.fetch; + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keyInfo = { + key: b64ToBytes(vectors.conversation_key_b64), + version: "1", + }; + xdk.chat.mediaUploadInitialize.mockResolvedValue({ + data: { sessionId: "sess-b", mediaHashKey: "hash-banner" }, + }); + xdk.chat.mediaUploadAppend.mockResolvedValue({ data: {} }); + xdk.chat.mediaUploadFinalize.mockResolvedValue({ data: {} }); + + const bannerBytes = new Uint8Array([1, 2, 3, 4, 5]); + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response(bannerBytes, { status: 200 })); + + const attachment = await (adapter as any).urlCardAttachment( + TEST_CONVERSATION_ID, + keyInfo, + { + url: "https://example.com/notes", + displayTitle: "Release notes", + imageUrl: "https://cdn.example.com/banner.png?v=2", + } + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://cdn.example.com/banner.png?v=2" + ); + expect(xdk.chat.mediaUploadFinalize).toHaveBeenCalledOnce(); + expect(attachment.attachment_type).toBe("url"); + expect(attachment.url).toBe("https://example.com/notes"); + expect(attachment.display_title).toBe("Release notes"); + expect(attachment.banner_image.media_hash_key).toBe("hash-banner"); + // Wire size is the encrypted blob, and the query string is stripped + expect(attachment.banner_image.filesize_bytes).toBeGreaterThan( + bannerBytes.length + ); + expect(attachment.banner_image.filename).toBe("banner.png"); + } finally { + globalThis.fetch = originalFetch; + restore(); + } + }); + + it("degrades to a card without a banner when the image fetch fails", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + const originalFetch = globalThis.fetch; + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + globalThis.fetch = vi.fn().mockRejectedValue(new Error("boom")); + + const attachment = await (adapter as any).urlCardAttachment( + TEST_CONVERSATION_ID, + { key: b64ToBytes(vectors.conversation_key_b64), version: "1" }, + { + url: "https://example.com/notes", + imageUrl: "https://cdn.example.com/x.png", + } + ); + + expect(attachment).toEqual({ + attachment_type: "url", + url: "https://example.com/notes", + }); + expect(xdk.chat.mediaUploadInitialize).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + restore(); + } + }); +}); + +describe("parseMessage edit events", () => { + it("renders an edit event as the edited message and maps its target sequence id", () => { + const adapter = createTestAdapter(); + const message = adapter.parseMessage({ + event: { + id: "edit-evt-1", + conversationId: TEST_CONVERSATION_ID, + senderId: TEST_OTHER_USER_ID, + encodedEvent: "x", + sequenceId: "seq-edit-event", + createdAtMsec: "1700000000000", + }, + decrypted: { + type: "message", + id: "edit-evt-1", + senderId: TEST_OTHER_USER_ID, + conversationId: TEST_CANONICAL_CONVERSATION_ID, + createdAtMsec: 1700000000000, + sequenceId: "seq-edit-event", + content: { + contentType: "edit", + targetMessageId: "seq-original", + newText: "the corrected words", + }, + verified: true, + } as any, + }); + + expect(message.text).toBe("the corrected words"); + // Later reactions/edits/deletes through this message must hit the + // original, not the edit event. + expect((adapter as any).sequenceIdByMessageId.get("edit-evt-1")).toBe( + "seq-original" + ); + }); +}); + +describe("editMessage (real crypto)", () => { + it("encrypts an edit targeting the cached sequence id and sends it", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + (adapter as any).sequenceIdByMessageId.set("orig-1", "seq-orig"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + + const engine = (adapter as any).cryptoEngine; + const editSpy = vi.spyOn(engine, "encryptEdit"); + + const result = await adapter.editMessage( + TEST_THREAD_ID, + "orig-1", + "fixed: see https://example.com" + ); + + const params = editSpy.mock.calls[0][0] as Record; + expect(params.targetMessageSequenceId).toBe("seq-orig"); + expect(params.updatedText).toBe("fixed: see https://example.com"); + // The URL in the replacement text stays tappable. + expect( + (params.entities as [number, number, string][]).some( + ([, , kind]) => kind === "url" + ) + ).toBe(true); + + // The edit ships through the regular send channel with its own id. + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + const [, payload] = (xdk.chat.sendMessage as any).mock.calls[0]; + expect(payload.encodedMessageCreateEvent).toBeTruthy(); + expect(payload.messageId).not.toBe("orig-1"); + // The returned message keeps the edited message's id. + expect(result.id).toBe("orig-1"); + } finally { + restore(); + } + }); + + it("holds the first edit until the original message settles", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + const tokensMap = (adapter as any).conversationTokens as Map< + string, + string + >; + tokensMap.set(TEST_CONVERSATION_ID, "conv-token-123"); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + (adapter as any).editSafetyDelayMs = 120; + + const posted = await adapter.postMessage(TEST_THREAD_ID, "v1"); + (adapter as any).sequenceIdByMessageId.set(posted.id, "seq-v1"); + + const started = Date.now(); + await adapter.editMessage(TEST_THREAD_ID, posted.id, "v2"); + expect(Date.now() - started).toBeGreaterThanOrEqual(100); + + // A message not posted in this process (no recorded post time) is not + // delayed. + (adapter as any).sequenceIdByMessageId.set("old-msg", "seq-old"); + const startedOld = Date.now(); + await adapter.editMessage(TEST_THREAD_ID, "old-msg", "v2"); + expect(Date.now() - startedOld).toBeLessThan(100); + } finally { + restore(); + } + }); + + it("recovers the sequence id from history when it is not cached", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(TEST_CONVERSATION_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + xdk.chat.sendMessage = vi.fn().mockResolvedValue({ data: {} }); + // History replay repopulates the sequence-id cache via parseMessage. + vi.spyOn(adapter, "fetchMessages").mockImplementation(async () => { + (adapter as any).sequenceIdByMessageId.set("orig-2", "seq-2"); + return { messages: [] }; + }); + + await adapter.editMessage(TEST_THREAD_ID, "orig-2", "updated"); + + expect(adapter.fetchMessages).toHaveBeenCalledWith(TEST_THREAD_ID, { + limit: 50, + }); + expect(xdk.chat.sendMessage).toHaveBeenCalledOnce(); + } finally { + restore(); + } + }); + + it("throws when the sequence id cannot be resolved", async () => { + const { adapter, restore } = await createInitializedTestAdapter(); + try { + vi.spyOn(adapter, "fetchMessages").mockResolvedValue({ messages: [] }); + await expect( + adapter.editMessage(TEST_THREAD_ID, "unknown-msg", "text") + ).rejects.toThrow(NO_SEQUENCE_ID_RE); + } finally { + restore(); + } + }); +}); + +describe("deleteMessage (real crypto)", () => { + it("sends a signed delete-for-all through the typed client", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + (adapter as any).sequenceIdByMessageId.set("msg-del", "seq-del"); + xdk.chat.deleteMessages.mockResolvedValue({ data: {} }); + + await adapter.deleteMessage(TEST_THREAD_ID, "msg-del"); + + expect(xdk.chat.deleteMessages).toHaveBeenCalledOnce(); + const [convId, body] = xdk.chat.deleteMessages.mock.calls[0]; + // Delete routes take the dash-joined pair id in the URL. + expect(convId).toBe(TEST_CONVERSATION_ID); + expect(body.sequenceIds).toEqual(["seq-del"]); + expect(body.deleteMessageAction).toBe("delete_for_all"); + const sig = body.actionSignatures[0]; + expect(sig.messageId).toBeTruthy(); + expect(sig.encodedMessageEventDetail).toBeTruthy(); + // The signed payload covers the canonical colon-form conversation id. + expect(sig.signaturePayload).toBe( + `MessageDeleteEvent,${sig.messageId},${TEST_USER_ID},${TEST_CANONICAL_CONVERSATION_ID},2,seq-del` + ); + expect(sig.messageEventSignature.signature).toBeTruthy(); + expect(sig.messageEventSignature.signatureVersion).toBe("7"); + expect(sig.messageEventSignature.publicKeyVersion).toBe("1"); + expect(sig.messageEventSignature.signingPublicKey).toBeTruthy(); + } finally { + restore(); + } + }); + + it("propagates client errors from the delete call", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + (adapter as any).sequenceIdByMessageId.set("msg-404", "seq-404"); + xdk.chat.deleteMessages.mockRejectedValue(new Error("HTTP 404")); + + await expect( + adapter.deleteMessage(TEST_THREAD_ID, "msg-404") + ).rejects.toThrow(HTTP_404_RE); + } finally { + restore(); + } + }); +}); + +describe("openDM (real crypto)", () => { + const CANONICAL_ID = TEST_CANONICAL_CONVERSATION_ID; // "12345:67890" + const EXPECTED_THREAD_ID = `xchat:${CANONICAL_ID}`; + + function makeSigningKeyData() { + const vectors = loadVectors(); + return { + publicKeyVersion: "1", + publicKey: vectors.identity_public_b64, + signingPublicKey: vectors.signing_public_b64, + identityPublicKeySignature: vectors.identity_public_key_signature_b64, + }; + } + + it("returns the existing thread when a conversation key is already cached", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + keysMap.set(CANONICAL_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + + const threadId = await adapter.openDM(TEST_OTHER_USER_ID); + + expect(threadId).toBe(EXPECTED_THREAD_ID); + expect(xdk.chat.getConversationEvents).not.toHaveBeenCalled(); + expect(xdk.chat.addConversationKeys).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("reuses a key recovered from conversation history without a key exchange", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + const vectors = loadVectors(); + const keysMap = (adapter as any).conversationKeys as Map; + vi.spyOn(adapter, "fetchMessages").mockImplementation(async () => { + // History replay extracts the conversation key into the cache. + keysMap.set(CANONICAL_ID, { + keys: { "1": b64ToBytes(vectors.conversation_key_b64) }, + latestVersion: "1", + }); + return { messages: [] }; + }); + + const threadId = await adapter.openDM(TEST_OTHER_USER_ID); + + expect(threadId).toBe(EXPECTED_THREAD_ID); + expect(adapter.fetchMessages).toHaveBeenCalledWith(EXPECTED_THREAD_ID, { + limit: 50, + }); + expect(xdk.chat.addConversationKeys).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); + + it("performs a key exchange for a brand-new conversation", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + xdk.chat.getConversationEvents = vi + .fn() + .mockResolvedValue({ data: [], meta: {} }); + xdk.users.getPublicKey = vi + .fn() + .mockResolvedValue({ data: [makeSigningKeyData()] }); + xdk.chat.addConversationKeys.mockResolvedValue({ data: {} }); + + const threadId = await adapter.openDM(TEST_OTHER_USER_ID); + + expect(threadId).toBe(EXPECTED_THREAD_ID); + expect(xdk.chat.addConversationKeys).toHaveBeenCalledOnce(); + const [pathId, body] = xdk.chat.addConversationKeys.mock.calls[0]; + // Key-initialization path params take the dash-joined pair id + expect(pathId).toBe(TEST_CONVERSATION_ID); + expect(body.conversationKeyVersion).toBeTruthy(); + expect(body.conversationParticipantKeys).toHaveLength(2); + const participantIds = body.conversationParticipantKeys.map( + (k: { userId: string }) => k.userId + ); + expect(participantIds).toContain(TEST_USER_ID); + expect(participantIds).toContain(TEST_OTHER_USER_ID); + for (const key of body.conversationParticipantKeys) { + expect(key.encryptedConversationKey).toBeTruthy(); + expect(key.publicKeyVersion).toBe("1"); + } + expect(body.actionSignatures.length).toBeGreaterThan(0); + for (const sig of body.actionSignatures) { + expect(sig.messageId).toBeTruthy(); + expect(sig.encodedMessageEventDetail).toBeTruthy(); + expect(sig.messageEventSignature.signature).toBeTruthy(); + expect(sig.messageEventSignature.signingPublicKey).toBeTruthy(); + } + + // The new key is cached, so the bot can post immediately. + const keysMap = (adapter as any).conversationKeys as Map; + const cached = keysMap.get(CANONICAL_ID); + expect(cached?.latestVersion).toBe(body.conversationKeyVersion); + expect(cached?.keys[body.conversationKeyVersion]).toBeInstanceOf( + Uint8Array + ); + } finally { + restore(); + } + }); + + it("throws when the recipient has no registered chat keys", async () => { + const { adapter, getXdkClient, restore } = + await createInitializedTestAdapter(); + try { + const xdk = getXdkClient(); + xdk.chat.getConversationEvents = vi + .fn() + .mockResolvedValue({ data: [], meta: {} }); + xdk.users.getPublicKey = vi + .fn() + .mockImplementation(async (userId: string) => + userId === TEST_USER_ID + ? { data: [makeSigningKeyData()] } + : { data: [] } + ); + + await expect(adapter.openDM(TEST_OTHER_USER_ID)).rejects.toThrow( + NO_REGISTERED_CHAT_KEYS_RE + ); + expect(xdk.chat.addConversationKeys).not.toHaveBeenCalled(); + } finally { + restore(); + } + }); +}); diff --git a/packages/adapter-xchat/src/index.ts b/packages/adapter-xchat/src/index.ts new file mode 100644 index 00000000..2311a266 --- /dev/null +++ b/packages/adapter-xchat/src/index.ts @@ -0,0 +1,2856 @@ +/** + * XChat adapter for chat SDK. + * + * Provides encrypted messaging via the X API (/2/chat/*) and chat-xdk WASM. + * All conversations are encrypted — the adapter handles key extraction, decryption, + * encryption, and signing transparently. + * + * @example + * ```typescript + * import { Chat } from "chat"; + * import { createXchatAdapter } from "@chat-adapter/xchat"; + * import { MemoryState } from "@chat-adapter/state-memory"; + * + * const chat = new Chat({ + * userName: "my-bot", + * adapters: { + * xchat: createXchatAdapter(), + * }, + * state: new MemoryState(), + * }); + * ``` + */ + +import { createHmac, timingSafeEqual } from "node:crypto"; +import { createRequire } from "node:module"; +import { ValidationError } from "@chat-adapter/shared"; +import { + type AttachmentDescriptor, + type ChatWithJuicebox, + type ConversationKeyResult, + createChat, + detectImageDimensions, + detectMimeType, + type EncryptMessageParams, + type EntityTuple, + type PublicKeyInput, + type SendPayload, + type SigningKeyEntry, + type UrlAttachmentImageDescriptor, +} from "@xdevplatform/chat-xdk"; +import { Client } from "@xdevplatform/xdk"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + Author, + CardElement, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FileUpload, + FormattedContent, + Logger, + RawMessage, + ReactionEvent, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + defaultEmojiResolver, + getEmoji, + isCardElement, + Message, + parseMarkdown, +} from "chat"; +import { cardToXChat, type XchatUrlCardSpec } from "./cards"; +import { XchatFormatConverter } from "./markdown"; +import type { + XchatAdapterConfig, + XchatAttachmentEntry, + XchatCryptoStatus, + XchatDecryptedEvent, + XchatEvent, + XchatRawMessage, + XchatThreadId, +} from "./types"; + +/** Default base URL for X API requests */ +const DEFAULT_API_BASE_URL = "https://api.x.com"; + +/** Adapter version, read from package.json (works from both src and dist). */ +const ADAPTER_VERSION: string = createRequire(import.meta.url)( + "../package.json" +).version; + +/** Product token identifying Chat SDK traffic in X API request logs */ +const CHAT_SDK_UA_TOKEN = `chat-sdk-xchat/${ADAPTER_VERSION}`; + +/** + * Prepend the Chat SDK product token to a User-Agent, keeping the existing + * value (the xdk client's own token) so both products stay identifiable. + * No-op when the token is already present. + */ +export function withChatSdkUserAgent(existing: string | null): string { + if (!existing) { + return CHAT_SDK_UA_TOKEN; + } + if (existing.includes("chat-sdk-xchat/")) { + return existing; + } + return `${CHAT_SDK_UA_TOKEN} ${existing}`; +} + +/** Re-send the typing indicator this often while a handler is running */ +const TYPING_INTERVAL_MS = 3000; +/** Stop the typing keep-alive loop after this long regardless */ +const TYPING_MAX_MS = 120_000; + +/** Media upload append chunk size (pre-base64) */ +const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024; + +/** Cap on the messageId → sequenceId cache used for reactions/read receipts */ +const SEQUENCE_CACHE_MAX = 1000; +/** + * Minimum age a freshly posted message must reach before its first edit is + * sent. Receiving clients apply an edit to their stored copy of the original + * and park it when the original has not arrived yet; because the backend + * stops serving a superseded original, an edit that outruns delivery leaves + * the message permanently invisible to that client. + */ +const DEFAULT_EDIT_SAFETY_DELAY_MS = 5000; + +/** + * Fields requested for message events from GET + * /2/chat/conversations/{id}/events. This list must stay within the API's + * chat_message_event.fields enum — unknown values fail the whole request. + * The sequence id is not requestable here; it comes from the decrypted + * event's meta instead. + */ +const MESSAGE_EVENT_FIELDS = [ + "conversation_id", + "conversation_token", + "created_at", + "encoded_event", + "id", + "is_trusted", + "message_event_signature", + "previous_id", + "sender_id", +] as const; + +/** + * Field-list type for getConversationEvents. The API accepts `created_at`, + * which @xdevplatform/xdk's generated field union does not yet include, so + * call sites cast through this alias instead of the SDK's union. + */ +type MessageEventFields = NonNullable< + Parameters["chat"]["getConversationEvents"]>[1] +>["chatMessageEventFields"]; + +/** + * Public-key fields requested when fetching participants' signing keys. + * + * All four are needed to build a chat-xdk SigningKeyEntry that supports + * full signature + key-binding verification: + * - public_key_version → publicKeyVersion + * - signing_public_key → publicKey (the signing key itself) + * - public_key → identityPublicKey + * - identity_public_key_signature → identityPublicKeySignature + */ +const SIGNING_KEY_FIELDS = [ + "public_key_version", + "public_key", + "signing_public_key", + "identity_public_key_signature", +] as const; + +/** Splits a 1:1 conversation ID into its participant IDs. */ +const CONVERSATION_ID_SEPARATOR = /[-:]/; +/** Matches a numeric X user ID. */ +const NUMERIC_USER_ID = /^\d+$/; + +/** Prefer `publicKeyVersion`; older API responses carry the same value as `version`. */ +function publicKeyVersionOf(key: { + publicKeyVersion?: string; + version?: string; +}): string | undefined { + return key.publicKeyVersion ?? key.version; +} + +/** + * Public key entry from users.getPublicKey — only the fields the adapter + * reads. `version` is the older-response alias for `publicKeyVersion`. + */ +interface PublicKeyData { + identityPublicKeySignature?: string; + juiceboxConfig?: { + keyStoreTokenMapJson?: string; + tokenMap?: Array<{ + key: string; + value: { address: string; token: string }; + }>; + }; + /** Identity public key (base64) */ + publicKey?: string; + publicKeyVersion?: string; + /** Signing public key (base64) */ + signingPublicKey?: string; + version?: string; +} + +/** Minimal X API response envelope (`data` shape varies per endpoint). */ +interface XApiResponse { + data?: T; +} + +/** + * Response envelope for GET /2/chat/conversations/{id}/events. Events carry + * the same fields as webhook events; `next_token` covers untransformed + * snake_case responses. + */ +interface ConversationEventsResponse { + data?: Partial[]; + meta?: { nextToken?: string; next_token?: string }; +} + +/** + * Path segment for chat REST routes (`/2/chat/conversations/{id}/…`). + * + * 1:1 conversations accept the other participant's user id (no `:`/`-` + * composite). Groups use the opaque `g…` conversation id as-is. + */ +export function conversationPathId( + conversationId: string, + selfUserId: string +): string { + if (conversationId.startsWith("g")) { + return conversationId; + } + const other = conversationId + .split(CONVERSATION_ID_SEPARATOR) + .filter((part) => NUMERIC_USER_ID.test(part)) + .find((id) => id !== selfUserId); + return other ?? conversationId; +} + +/** + * Conversation id for routes that take it as a path parameter (media + * download, key initialization), which accept only the dash-joined + * participant pair or the g-prefixed group id. + */ +export function dashConversationId(conversationId: string): string { + if (conversationId.startsWith("g")) { + return conversationId; + } + return conversationId.replace(":", "-"); +} + +// ── Outgoing entity + attachment detection ──────────────────────────── + +/** + * URL matcher for outgoing entities: full http(s) URLs plus common bare + * domains. False positives are low-cost (link underline); false negatives + * mean no tappable link. + */ +const URL_ENTITY_RE = + /https?:\/\/[^\s\])>"']+|(?"']*)?/gi; + +/** @handle matcher (1-15 word chars); lookbehind excludes email-like text. */ +const MENTION_ENTITY_RE = + /(?/status/ or twitter.com//status/ */ +const POST_URL_RE = + /https?:\/\/(?:www\.)?(?:x|twitter)\.com\/[A-Za-z0-9_]+\/status\/(\d+)/gi; + +/** Strips a leading @ from a handle. */ +const LEADING_AT_RE = /^@/; + +/** + * Detect URL and @mention entities in outgoing text. + * + * Returns `[start, end, type]` tuples for `encryptMessage`/`encryptReply`, + * or null when nothing was found. Mentions overlapping a detected URL span + * (e.g. an @ inside a URL path) are skipped. + */ +export function detectEntities(text: string): EntityTuple[] | null { + const entities: EntityTuple[] = []; + for (const m of text.matchAll(URL_ENTITY_RE)) { + entities.push([m.index, m.index + m[0].length, "url"]); + } + for (const m of text.matchAll(MENTION_ENTITY_RE)) { + const start = m.index; + if (entities.some((e) => e[0] <= start && start < e[1])) { + continue; + } + entities.push([start, start + m[0].length, "mention"]); + } + entities.sort((a, b) => a[0] - b[0]); + return entities.length > 0 ? entities : null; +} + +/** + * Extract X post URLs from text as post-card attachment descriptors, + * deduplicated by post id. Returns null when none found. + */ +export function extractPostAttachments( + text: string +): AttachmentDescriptor[] | null { + const seen = new Set(); + const attachments: AttachmentDescriptor[] = []; + for (const m of text.matchAll(POST_URL_RE)) { + const postId = m[1]; + if (seen.has(postId)) { + continue; + } + seen.add(postId); + attachments.push({ + attachment_type: "post", + rest_id: postId, + post_url: m[0], + }); + } + return attachments.length > 0 ? attachments : null; +} + +/** The card carried by a postable message, if any. */ +function cardOf(message: AdapterPostableMessage): CardElement | null { + if (typeof message !== "object" || message === null) { + return null; + } + if ("card" in message && isCardElement(message.card)) { + return message.card; + } + return isCardElement(message) ? message : null; +} + +// ── Inbound event helpers ───────────────────────────────────────────── + +/** Tolerant accessor: chat-xdk JS emits camelCase, wire payloads snake_case. */ +function fieldOf(obj: unknown, camel: string, snake: string): unknown { + if (!obj || typeof obj !== "object") { + return undefined; + } + const rec = obj as Record; + return rec[camel] ?? rec[snake]; +} + +/** + * Extract @-mentioned handles from a decrypted message's rich-text + * entities. Handles are lowercased, without the leading `@`, deduplicated. + */ +export function mentionHandlesFromEntities( + text: string, + entities: unknown[] | undefined +): string[] { + if (!entities || entities.length === 0 || !text) { + return []; + } + const seen = new Set(); + const handles: string[] = []; + for (const entity of entities) { + if (!entity || typeof entity !== "object") { + continue; + } + const content = (entity as Record).content; + if (!content || typeof content !== "object") { + continue; + } + if (!("mention" in (content as Record))) { + continue; + } + const startRaw = fieldOf(entity, "startIndex", "start_index"); + const endRaw = fieldOf(entity, "endIndex", "end_index"); + if (startRaw == null || endRaw == null) { + continue; + } + const start = Math.max(0, Number(startRaw)); + const end = Math.min(text.length, Number(endRaw)); + if (!(Number.isFinite(start) && Number.isFinite(end)) || start >= end) { + continue; + } + const handle = text + .slice(start, end) + .replace(LEADING_AT_RE, "") + .toLowerCase(); + if (handle && !seen.has(handle)) { + seen.add(handle); + handles.push(handle); + } + } + return handles; +} + +/** Reply-to preview (swipe reply) from decrypted content, if present. */ +function replyPreviewOf( + content: XchatDecryptedEvent["content"] +): { senderId?: string; text?: string } | null { + const preview = fieldOf(content, "replyingToPreview", "replying_to_preview"); + if (!preview || typeof preview !== "object") { + return null; + } + const senderId = fieldOf(preview, "senderId", "sender_id"); + const text = (preview as Record).text; + return { + senderId: senderId == null ? undefined : String(senderId), + text: typeof text === "string" ? text : undefined, + }; +} + +/** Lowercased content type, tolerant of both binding spellings. */ +function contentTypeOf(content: XchatDecryptedEvent["content"]): string { + const ct = fieldOf(content, "contentType", "content_type"); + return typeof ct === "string" ? ct.toLowerCase() : ""; +} + +/** + * Whether a decrypted event is a processable chat message, and its text. + * + * Processable messages are: plain text; media (with or without caption); + * anything carrying attachments/media hashes; and card-like content with + * URL entities. Reactions/edits/mark-read return null. + */ +function processableText(decrypted: XchatDecryptedEvent | null): string | null { + if (!decrypted || decrypted.type.toLowerCase() !== "message") { + return null; + } + const content = decrypted.content ?? {}; + const text = typeof content.text === "string" ? content.text : ""; + const ct = contentTypeOf(content); + if (ct === "text" || ct === "media" || ct === "mediawithtext") { + return text; + } + if (decrypted.attachments?.length || decrypted.mediaHashes?.length) { + return text; + } + if (content.attachments?.length) { + return text; + } + for (const entity of content.entities ?? []) { + const ec = fieldOf(entity, "content", "content"); + if (ec && typeof ec === "object" && "url" in (ec as object)) { + return text; + } + } + return ct ? null : text; +} + +/** Normalized media reference extracted from a decrypted message. */ +interface MediaEntry { + durationMillis?: number; + filename?: string; + filesize?: number; + hashKey: string; + height?: number; + mediaType: string; + width?: number; +} + +function mediaTypeFromSource(source: string | undefined): string { + const value = (source ?? "").toLowerCase(); + if (value.includes("gif")) { + return "gif"; + } + if (value.includes("video")) { + return "video"; + } + if (value.includes("image")) { + return "image"; + } + return "media"; +} + +/** Extract all media references (hash keys + metadata) from a decrypted event. */ +export function extractMediaEntries( + decrypted: XchatDecryptedEvent +): MediaEntry[] { + const entries: MediaEntry[] = []; + const seen = new Set(); + + const addFromAttachment = (entry: XchatAttachmentEntry): void => { + // Nested `{ media: {...} }` (wire shape) or flattened (JS binding shape) + const media = entry.media ?? entry; + const hashKey = + fieldOf(media, "mediaHashKey", "media_hash_key") ?? + (entry.attachmentType === "media" ? entry.mediaHashKey : undefined); + if (!hashKey || seen.has(String(hashKey))) { + return; + } + seen.add(String(hashKey)); + const dims = (media as XchatAttachmentEntry).dimensions; + const rawType = + fieldOf(media, "mediaType", "media_type") ?? + (media as Record).type; + entries.push({ + mediaType: String(rawType ?? "media").toLowerCase(), + hashKey: String(hashKey), + width: dims?.width, + height: dims?.height, + filename: (fieldOf(media, "filename", "filename") ?? undefined) as + | string + | undefined, + filesize: Number( + fieldOf(media, "filesizeBytes", "filesize_bytes") ?? Number.NaN + ), + durationMillis: Number( + fieldOf(media, "durationMillis", "duration_millis") ?? Number.NaN + ), + }); + }; + + for (const entry of decrypted.attachments ?? []) { + addFromAttachment(entry); + } + for (const entry of decrypted.content?.attachments ?? []) { + addFromAttachment(entry); + } + for (const ref of decrypted.mediaHashes ?? []) { + const hashKey = ref.mediaHashKey; + if (!hashKey || seen.has(hashKey)) { + continue; + } + seen.add(hashKey); + entries.push({ + mediaType: mediaTypeFromSource(ref.source), + hashKey, + }); + } + return entries; +} + +/** Map an XChat media type string onto the Chat SDK attachment type. */ +function chatAttachmentType(mediaType: string): Attachment["type"] { + if (mediaType === "video") { + return "video"; + } + if (mediaType === "audio") { + return "audio"; + } + if (mediaType === "image" || mediaType === "gif") { + return "image"; + } + return "file"; +} + +/** Numeric media_type for outgoing attachment descriptors, keyed by MIME. */ +function outgoingMediaType(mimeType: string): number | undefined { + if (mimeType === "image/gif") { + return 2; + } + if (mimeType.startsWith("video/")) { + return 3; + } + if (mimeType.startsWith("audio/")) { + return 4; + } + if (mimeType.startsWith("image/")) { + // Image is the server default. + return undefined; + } + return 5; +} + +/** Latest-message context per conversation, for group quote-replies + TTL. */ +interface InboundContext { + createdAtMsec?: number; + encodedEvent?: string; + senderId?: string; + sequenceId?: string; + text?: string; + ttlMsec?: number; +} + +/** Normalize the Chat SDK's binary payload shapes to Uint8Array. */ +async function toBytes( + data: Buffer | Blob | ArrayBuffer | Uint8Array +): Promise { + if (data instanceof Uint8Array) { + return data; + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + // Blob (Buffer is a Uint8Array subclass, handled above) + return new Uint8Array(await (data as Blob).arrayBuffer()); +} + +export type { XchatCardResult, XchatUrlCardSpec } from "./cards"; +export { cardToXChat } from "./cards"; +export { XchatFormatConverter } from "./markdown"; +export type { + XchatAdapterConfig, + XchatAttachmentEntry, + XchatCryptoStatus, + XchatDecryptedEvent, + XchatEvent, + XchatRawMessage, + XchatThreadId, +} from "./types"; + +/** + * XChat adapter for chat SDK. + * + * Handles encrypted messaging via the X API and chat-xdk WASM crypto. + * All conversations are 1:1 DMs or group chats, always encrypted. + */ +export class XchatAdapter implements Adapter { + readonly name = "xchat"; + readonly persistMessageHistory = true; + private _userName: string; + private readonly userNameFromConfig: boolean; + + private readonly accessToken: string; + private readonly apiHeaders: Record; + private readonly consumerSecret: string | undefined; + /** Bot user id (from config or resolved from GET /2/users/me) */ + private userId: string; + /** Populated during initialize() from the X API, or from config override */ + private signingKeyVersion: string | null; + /** Juicebox PIN; when set, initialize() auto-unlocks after createChat */ + private readonly pin: string | undefined; + /** When false, decryption proceeds even for unverifiable signatures */ + private readonly verifySignatures: boolean; + private chat: ChatInstance | null = null; + private readonly logger: Logger; + private readonly formatConverter = new XchatFormatConverter(); + private xdkClient: InstanceType | null = null; + private cryptoEngine: ChatWithJuicebox | null = null; + private _cryptoStatus: XchatCryptoStatus = "uninitialized"; + /** Saved getAuthToken callback for re-installing on unlock() */ + private _getAuthToken: ((realmId: string) => Promise) | null = null; + /** + * Cached conversation keys: conversationId → { version → raw bytes }. + * Grows by one small entry per conversation the bot participates in; + * evicting a key would only force a re-fetch through conversation history, + * so the cache is kept unbounded for the process lifetime. + */ + private readonly conversationKeys = new Map(); + /** Cached conversation tokens: conversationId → token (one per conversation) */ + private readonly conversationTokens = new Map(); + /** Cached signing keys: userId → SigningKeyEntry[] (one per key version, one entry per user ever seen) */ + private readonly signingKeyCache = new Map(); + /** Base URL for X API requests */ + private readonly apiBaseUrl: string; + /** Welcome message posted on group join; false disables */ + private readonly welcomeMessage: string | false | undefined; + /** Latest inbound message context per conversation (quote-replies, TTL) */ + private readonly lastInboundByConv = new Map(); + /** messageId → sequenceId, for reactions + per-message read receipts */ + private readonly sequenceIdByMessageId = new Map(); + /** sequenceId → messageId, for resolving inbound reaction targets */ + private readonly messageIdBySequenceId = new Map(); + /** messageId → post time, for age-gating the first edit of a fresh message */ + private readonly postedAtByMessageId = new Map(); + /** Minimum age of a message before its first edit is sent */ + private readonly editSafetyDelayMs: number; + /** Whether a read receipt is sent for each delivered inbound message */ + private readonly sendReadReceipts: boolean; + /** Active typing keep-alive timers per conversation */ + private readonly typingTimers = new Map< + string, + ReturnType + >(); + + /** Bot @handle used for mention detection (from config or GET /2/users/me) */ + get userName(): string { + return this._userName; + } + + /** Bot user ID (same as userId for XChat) */ + get botUserId(): string | undefined { + return this.userId; + } + + /** Current encryption readiness status */ + get cryptoStatus(): XchatCryptoStatus { + return this._cryptoStatus; + } + + constructor( + config: XchatAdapterConfig & { + accessToken: string; + logger: Logger; + /** Skips the /2/users/me identity resolution (used in tests). */ + userId?: string; + } + ) { + this.accessToken = config.accessToken; + this.apiHeaders = { ...config.apiHeaders }; + this.apiBaseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE_URL; + this.consumerSecret = config.consumerSecret; + this.userId = config.userId ?? ""; + this.signingKeyVersion = config.signingKeyVersion ?? null; + this.pin = config.pin; + this.verifySignatures = config.verifySignatures ?? true; + this.welcomeMessage = config.welcomeMessage; + this.editSafetyDelayMs = + config.editSafetyDelayMs ?? DEFAULT_EDIT_SAFETY_DELAY_MS; + this.sendReadReceipts = config.sendReadReceipts !== false; + this.userNameFromConfig = Boolean(config.userName); + this._userName = config.userName ?? "xchat-bot"; + this.logger = config.logger; + } + + // ── Adapter lifecycle ───────────────────────────────────────────── + + /** + * Initialize the adapter. + * + * 1. Create the XDK API client (and resolve the bot @handle when needed) + * 2. Fetch signingKeyVersion + Juicebox config from users.getPublicKey() + * 3. Build the realm token cache from the initial response + * 4. createChat() (Juicebox-backed crypto); when `pin` was provided → + * unlock(pin), otherwise status stays `"locked"` + */ + async initialize(chatInstance: ChatInstance): Promise { + this.chat = chatInstance; + this._cryptoStatus = "initializing"; + + if (!this.consumerSecret) { + this.logger.warn( + "XChat adapter: consumerSecret is not set — incoming webhook POSTs " + + "will not be signature-verified. Set X_CONSUMER_SECRET to enable " + + "webhook authentication." + ); + } + + try { + // 1. Initialize XDK API client + this.xdkClient = new Client({ + accessToken: this.accessToken, + baseUrl: this.apiBaseUrl, + headers: this.apiHeaders, + }); + + // Mark this client's traffic as Chat SDK in X API request logs by + // prepending our product token to the xdk client's User-Agent. A + // User-Agent supplied via apiHeaders wins and is left untouched. + const userAgentFromConfig = Object.keys(this.apiHeaders).some( + (name) => name.toLowerCase() === "user-agent" + ); + if (!userAgentFromConfig) { + this.xdkClient.headers.set( + "user-agent", + withChatSdkUserAgent(this.xdkClient.headers.get("user-agent")) + ); + } + + // Resolve identity from /2/users/me unless the caller provided both + // the user id and the @handle explicitly. + if (!(this.userId && this.userNameFromConfig)) { + const me = await this.xdkClient.users.getMe(); + const id = me?.data?.id; + const username = me?.data?.username; + if (!this.userId) { + if (typeof id !== "string" || id.length === 0) { + throw new Error( + "GET /2/users/me did not return a user id for the configured token" + ); + } + this.userId = id; + } + if (!this.userNameFromConfig) { + if (typeof username === "string" && username.length > 0) { + this._userName = username; + } else { + this.logger.warn( + "GET /2/users/me did not return a username; keeping placeholder", + { userName: this._userName } + ); + } + } + this.logger.info("Resolved bot identity from /2/users/me", { + userId: this.userId, + userName: this._userName, + }); + } + + // 2. Fetch signing key version + Juicebox config from the X API + // (signingKeyVersion may already be set via config override) + // + // XDK response (camelCase — transformKeys converts snake_case from API): + // { + // data: [{ + // publicKeyVersion: "1733889755256", + // juiceboxConfig: { + // keyStoreTokenMapJson: "{ \"realms\": [...], ... }", + // maxGuessCount: 20, + // tokenMap: [{ key: "realmHex", value: { address: "...", token: "jwt" } }] + // } + // }] + // } + let juiceboxConfigJson: string | null = null; + let tokenMapEntries: Array<{ + key: string; + value: { address: string; token: string }; + }> = []; + + const response = await this.xdkClient.users.getPublicKey(this.userId, { + publicKeyFields: ["public_key_version", "juicebox_config"], + }); + const keys = (response as XApiResponse).data ?? []; + if (keys.length === 0) { + throw new Error( + "No public key data returned from users.getPublicKey()" + ); + } + // Pick the latest version (highest numeric version string) + const publicKey = keys.reduce((latest, current) => { + const latestVersion = publicKeyVersionOf(latest) ?? "0"; + const currentVersion = publicKeyVersionOf(current) ?? "0"; + return Number(currentVersion) > Number(latestVersion) + ? current + : latest; + }); + + if (!this.signingKeyVersion) { + this.signingKeyVersion = publicKeyVersionOf(publicKey) ?? null; + } + const jbConfig = publicKey.juiceboxConfig; + if (jbConfig) { + juiceboxConfigJson = jbConfig.keyStoreTokenMapJson ?? null; + tokenMapEntries = jbConfig.tokenMap ?? []; + } + + this.logger.debug("Fetched public key info", { + signingKeyVersion: this.signingKeyVersion, + hasJuiceboxConfig: !!juiceboxConfigJson, + realmCount: tokenMapEntries.length, + }); + + if (!juiceboxConfigJson) { + // Throw like every other initialization failure so a misconfigured + // bot fails fast instead of surfacing "locked" errors per call. + throw new Error( + "XChat adapter: no Juicebox config found on the user's public key. " + + "Register keys (setup/PIN) before initializing the adapter." + ); + } + + // 3. Build realm token cache from the initial response + const realmTokenCache = new Map(); + for (const entry of tokenMapEntries) { + realmTokenCache.set(entry.key, entry.value.token); + } + + // 4. createChat (Juicebox) — unlock with pin when provided + const xdk = this.xdkClient; + const userId = this.userId; + const logger = this.logger; + + const getAuthToken = async (realmId: string): Promise => { + const cached = realmTokenCache.get(realmId); + if (cached) { + return cached; + } + + logger.debug("Fetching fresh Juicebox realm token", { realmId }); + const freshResponse = await xdk.users.getPublicKey(userId, { + publicKeyFields: ["juicebox_config"], + }); + const freshKeys = + (freshResponse as XApiResponse).data ?? []; + const freshKey = freshKeys.reduce((latest, current) => { + const latestVersion = publicKeyVersionOf(latest) ?? "0"; + const currentVersion = publicKeyVersionOf(current) ?? "0"; + return Number(currentVersion) > Number(latestVersion) + ? current + : latest; + }); + const freshEntries: Array<{ key: string; value: { token: string } }> = + freshKey?.juiceboxConfig?.tokenMap ?? []; + + for (const entry of freshEntries) { + realmTokenCache.set(entry.key, entry.value.token); + } + + const token = realmTokenCache.get(realmId); + if (!token) { + throw new Error(`No Juicebox auth token found for realm ${realmId}`); + } + return token; + }; + + this._getAuthToken = getAuthToken; + + const crypto = await createChat({ + juiceboxConfig: juiceboxConfigJson, + getAuthToken, + }); + // chat-xdk defaults to reject-unverified; only override when opting out + if (!this.verifySignatures) { + crypto.setRejectUnverified(false); + } + this.cryptoEngine = crypto; + this._cryptoStatus = "locked"; + this.logger.info("XChat adapter initialized (Juicebox, locked)", { + userId: this.userId, + userName: this._userName, + realmCount: realmTokenCache.size, + }); + + if (this.pin) { + await this.unlock(this.pin); + } + } catch (err) { + this._cryptoStatus = "error"; + this.logger.error("XChat adapter initialization failed", { error: err }); + throw err; + } + } + + /** + * Unlock encryption keys with a Juicebox PIN. + * + * Only available when `cryptoStatus === "locked"`. + * After successful unlock, `cryptoStatus` becomes `"ready"`. + */ + async unlock(pin: string): Promise { + if (this._cryptoStatus !== "locked") { + throw new Error( + `Cannot unlock: adapter is "${this._cryptoStatus}" (expected "locked"). ` + + (this._cryptoStatus === "ready" + ? "Already unlocked." + : "Call initialize() first.") + ); + } + + // chat-xdk's createChat() sets globalThis.JuiceboxGetAuthToken only during + // construction, then restores the previous value. But the Juicebox SDK + // needs the global set when recover() is called during unlock(). + // Install this adapter's getAuthToken callback for the duration of the + // unlock call, then restore the previous value so multiple adapters in + // one process don't clobber each other's callback. + interface JuiceboxGlobal { + JuiceboxGetAuthToken?: (realmId: Uint8Array | string) => Promise; + } + const jbGlobal = globalThis as JuiceboxGlobal; + const previousGetAuthToken = jbGlobal.JuiceboxGetAuthToken; + const getAuthToken = this._getAuthToken; + if (getAuthToken) { + jbGlobal.JuiceboxGetAuthToken = async (realmId: Uint8Array | string) => { + const realmIdHex = + typeof realmId === "string" + ? realmId + : Array.from(realmId) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return await getAuthToken(realmIdHex); + }; + } + + const jbChat = this.cryptoEngine as ChatWithJuicebox; + try { + await jbChat.unlock(pin); + } finally { + jbGlobal.JuiceboxGetAuthToken = previousGetAuthToken; + } + // Establish the session identity (sender id + registered signing key + // version) so chat-xdk signs with the right key version and handles + // KeyChange events for other versions correctly. + if (this.signingKeyVersion) { + try { + jbChat.setIdentity(this.userId, this.signingKeyVersion); + } catch (err) { + this.logger.warn("setIdentity failed", { error: err }); + } + } + this._cryptoStatus = "ready"; + this.logger.info("XChat adapter unlocked via PIN"); + } + + // ── Thread ID encoding ──────────────────────────────────────────── + + encodeThreadId(data: XchatThreadId): string { + return `xchat:${data.conversationId}`; + } + + decodeThreadId(threadId: string): XchatThreadId { + if (!threadId.startsWith("xchat:")) { + throw new Error(`Invalid XChat thread ID: ${threadId}`); + } + const conversationId = threadId.slice("xchat:".length); + if (!conversationId) { + throw new Error(`Invalid XChat thread ID format: ${threadId}`); + } + return { conversationId }; + } + + channelIdFromThreadId(threadId: string): string { + // XChat doesn't have channels — the conversation IS the channel + return threadId; + } + + // ── Webhook handling ────────────────────────────────────────────── + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + if (request.method !== "POST") { + // CRC challenges (GET) should be handled at the route level, + // not here — they don't need adapter initialization or crypto. + return new Response("Method not allowed", { status: 405 }); + } + + // Read the raw body for signature verification + const rawBody = await request.text(); + + // Verify webhook signature if consumer secret is configured + if (this.consumerSecret) { + const signature = request.headers.get("x-twitter-webhooks-signature"); + if (!signature) { + this.logger.warn("webhook_missing_signature"); + return new Response("Missing signature", { status: 401 }); + } + if (!this.verifySignature(rawBody, signature)) { + this.logger.warn("webhook_invalid_signature"); + return new Response("Invalid signature", { status: 401 }); + } + } + + // Parse the XAA webhook envelope and extract the event payload. + // + // XAA wraps the event in: + // { data: { event_type, event_uuid, filter, tag, payload: { ...event } } } + // Wire format is snake_case (raw HTTP body — not via XDK transformKeys). + let event: XchatEvent; + let eventType: string; + try { + const body = JSON.parse(rawBody); + const xaaData = body.data ?? body; + const payload = xaaData.payload ?? xaaData; + eventType = xaaData.event_type ?? "chat.received"; + const conversationId = String(payload.conversation_id ?? ""); + const encodedEvent = String(payload.encoded_event ?? ""); + if (!(conversationId && encodedEvent)) { + return new Response("OK", { status: 200 }); + } + event = { + id: String(payload.id ?? ""), + conversationId, + senderId: String(payload.sender_id ?? ""), + encodedEvent, + conversationKeyVersion: payload.conversation_key_version, + conversationKeyChangeEvent: payload.conversation_key_change_event, + conversationToken: payload.conversation_token, + encryptedConversationKey: payload.encrypted_conversation_key, + createdAtMsec: payload.created_at_msec, + messageEventSignature: payload.message_event_signature, + sequenceId: payload.sequence_id + ? String(payload.sequence_id) + : undefined, + }; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + // Handle conversation join (bot added to group) before the echo skip — + // the join event's sender may be the bot itself. + if (eventType === "chat.conversation_join") { + this.handleConversationJoin(event, options); + return new Response("OK", { status: 200 }); + } + + // Skip echo events (messages sent by the bot itself) + if (eventType === "chat.sent" || event.senderId === this.userId) { + return new Response("OK", { status: 200 }); + } + + // chat.received — decrypt, then route to the message or reaction pipeline + const threadId = this.encodeThreadId({ + conversationId: event.conversationId, + }); + + const task = (async () => { + const parsed = await this.decryptAndParseEvent(event); + const decrypted = parsed.raw?.decrypted ?? null; + const ct = contentTypeOf(decrypted?.content); + + if (ct === "reaction" || ct === "reactionremoved") { + this.routeReaction(threadId, parsed, ct === "reaction", options); + return; + } + + // Same guards as the poll path (handleIncomingEvent): events that + // failed decryption or signature verification, non-message events, + // and empty messages are dropped instead of delivered. + if (!decrypted) { + this.logger.debug("Skipping undecryptable event", { + conversationId: event.conversationId, + eventId: event.id, + }); + return; + } + + const text = processableText(decrypted); + if (text === null) { + this.logger.debug("Skipping non-message event", { + conversationId: event.conversationId, + contentType: ct || decrypted.type, + eventId: event.id, + }); + return; + } + + if (decrypted.verified === false) { + this.logger.warn("Skipping unverified event", { + conversationId: event.conversationId, + eventId: event.id, + }); + return; + } + + if (!text.trim() && parsed.attachments.length === 0) { + return; + } + + // Read receipt before handlers run; markAsRead logs and swallows its + // own failures, so delivery is never blocked by a receipt error. + if (this.sendReadReceipts) { + await this.markAsRead(threadId, parsed); + } + + await this.chat?.handleIncomingMessage(this, threadId, parsed); + })().catch((err) => { + this.logger.error("Webhook event processing error", { + conversationId: event.conversationId, + eventId: event.id, + error: err instanceof Error ? err.message : String(err), + }); + }); + options?.waitUntil?.(task); + + return new Response("OK", { status: 200 }); + } + + /** + * Bot added to a conversation: bootstrap the conversation key from the + * KeyChange blob and post the welcome message (groups only). + */ + private handleConversationJoin( + event: XchatEvent, + options?: WebhookOptions + ): void { + const conversationId = event.conversationId; + this.logger.info("conversation_join", { conversationId }); + + if (this._cryptoStatus === "ready") { + const crypto = this.getCryptoEngine(); + for (const blob of [ + event.conversationKeyChangeEvent, + event.encodedEvent, + ]) { + if (!blob) { + continue; + } + try { + const extracted = crypto.extractConversationKeys([blob]); + this.mergeConversationKeys(conversationId, extracted); + } catch { + // Not a KeyChange blob — fine. + } + } + } + if (event.conversationToken) { + this.conversationTokens.set(conversationId, event.conversationToken); + } + + if (this.welcomeMessage === false || !conversationId.startsWith("g")) { + return; + } + const welcome = + this.welcomeMessage ?? + `Hey! I'm @${this._userName} — mention @${this._userName} in this group and I'll reply. Heads up: with a bot in the group, messages it can read are no longer fully private.`; + + const threadId = this.encodeThreadId({ conversationId }); + const task = this.postMessage(threadId, welcome).catch((err) => { + this.logger.warn("Failed to post welcome message", { + conversationId, + error: err instanceof Error ? err.message : String(err), + }); + }); + options?.waitUntil?.(task as Promise); + } + + /** Route a decrypted reaction event into the Chat SDK. */ + private routeReaction( + threadId: string, + parsed: Message, + added: boolean, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + const decrypted = parsed.raw?.decrypted; + const content = decrypted?.content ?? {}; + const rawEmoji = typeof content.emoji === "string" ? content.emoji : ""; + const targetMessageId = fieldOf( + content, + "targetMessageId", + "target_message_id" + ); + const senderId = decrypted?.senderId ?? parsed.raw?.event.senderId ?? ""; + + const user: Author = { + userId: senderId, + userName: senderId, + fullName: senderId, + isBot: false, + isMe: senderId === this.userId, + }; + + // A reaction targets its message by sequence id; resolve it back to the + // message id handlers saw. Falls back to the raw sequence id when the + // target is older than the bounded cache window. + const targetSequenceId = + targetMessageId == null ? "" : String(targetMessageId); + const resolvedMessageId = + this.messageIdBySequenceId.get(targetSequenceId) ?? targetSequenceId; + + const reactionEvent: Omit = { + emoji: getEmoji(rawEmoji), + rawEmoji, + added, + user, + messageId: resolvedMessageId, + threadId, + raw: parsed.raw, + }; + this.chat.processReaction({ ...reactionEvent, adapter: this }, options); + } + + /** + * Feed a polled conversation event into the Chat SDK. + * + * Expects an XDK camelCase event (transformKeys already applied). Decrypts + * + verifies via chat-xdk, then awaits `chat.handleIncomingMessage` so + * handlers (e.g. onDirectMessage) finish before this resolves. + * + * Returns the delivered Message, or null when the event was skipped + * (own message, non-message, decrypt failure, empty text). + */ + async handleIncomingEvent( + event: XchatEvent + ): Promise | null> { + if (!this.chat) { + throw new Error("XChat adapter not initialized — call Chat.initialize()"); + } + + if (!(event.conversationId && event.encodedEvent)) { + this.logger.debug( + "handleIncomingEvent: missing conversationId/encodedEvent" + ); + return null; + } + + if (event.senderId && event.senderId === this.userId) { + this.logger.debug("handleIncomingEvent: skip own message", { + id: event.id, + }); + return null; + } + + let message: Message; + try { + message = await this.decryptAndParseEvent(event); + } catch (err) { + this.logger.warn("handleIncomingEvent: decrypt failed", { + conversationId: event.conversationId, + eventId: event.id, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + const decrypted = message.raw?.decrypted ?? null; + const text = processableText(decrypted); + if (text === null) { + this.logger.debug("handleIncomingEvent: skip non-message", { + type: decrypted?.type, + contentType: contentTypeOf(decrypted?.content), + eventId: event.id, + }); + return null; + } + + if (decrypted?.verified === false) { + this.logger.warn("handleIncomingEvent: skip unverified", { + conversationId: event.conversationId, + eventId: event.id, + }); + return null; + } + + if (!text.trim() && message.attachments.length === 0) { + return null; + } + + const threadId = this.encodeThreadId({ + conversationId: event.conversationId, + }); + + // Read receipt before handlers run; markAsRead logs and swallows its + // own failures, so delivery is never blocked by a receipt error. + if (this.sendReadReceipts) { + await this.markAsRead(threadId, message); + } + + await this.chat.handleIncomingMessage(this, threadId, message); + return message; + } + + /** + * Verify the HMAC-SHA256 signature on an incoming webhook POST. + * + * The `x-twitter-webhooks-signature` header contains `sha256=` + * where the hash is HMAC-SHA256(consumer_secret, raw_body). + */ + private verifySignature(body: string, signature: string): boolean { + if (!this.consumerSecret) { + return false; + } + + const expected = createHmac("sha256", this.consumerSecret) + .update(body) + .digest("base64"); + + const expectedBuf = Buffer.from(`sha256=${expected}`); + const actualBuf = Buffer.from(signature); + + if (expectedBuf.length !== actualBuf.length) { + return false; + } + + return timingSafeEqual(expectedBuf, actualBuf); + } + + // ── Message parsing ─────────────────────────────────────────────── + + parseMessage(raw: XchatRawMessage): Message { + const { event, decrypted } = raw; + + // An edit replaces its target: the backend stops serving the superseded + // original, so the edit's replacement text IS the message text, and the + // edit's target sequence id is what later reactions/edits/deletes must + // reference. + const content = decrypted?.content ?? {}; + const isEdit = contentTypeOf(content) === "edit"; + const editText = fieldOf(content, "newText", "new_text"); + const editTarget = fieldOf(content, "targetMessageId", "target_message_id"); + const text = + isEdit && typeof editText === "string" + ? editText + : (decrypted?.content?.text ?? ""); + const senderId = decrypted?.senderId ?? event.senderId; + const createdAtMsec = + decrypted?.createdAtMsec ?? Number(event.createdAtMsec ?? 0); + const conversationId = event.conversationId; + const threadId = this.encodeThreadId({ conversationId }); + + const messageId = decrypted?.id ?? event.id; + const sequenceId = + isEdit && typeof editTarget === "string" && editTarget + ? editTarget + : (decrypted?.sequenceId ?? event.sequenceId); + + // Track sequence ids + latest inbound context so reactions, read + // receipts, and group quote-replies can reference this message later. + if (sequenceId && messageId) { + this.rememberSequenceId(messageId, sequenceId); + } + if (senderId && senderId !== this.userId && decrypted) { + // Messages are parsed in whatever order they arrive — history pages come + // newest-first — so only a message at least as recent as the stored one + // may become the quote-reply target. + const previous = this.lastInboundByConv.get(conversationId); + if (!previous || (previous.createdAtMsec ?? 0) <= createdAtMsec) { + this.lastInboundByConv.set(conversationId, { + createdAtMsec, + encodedEvent: event.encodedEvent || undefined, + sequenceId, + senderId, + text: text || undefined, + ttlMsec: + typeof decrypted.ttlMsec === "number" + ? decrypted.ttlMsec + : Number( + fieldOf(decrypted, "ttlMsec", "ttl_msec") ?? Number.NaN + ) || undefined, + }); + } + } + + const isMention = this.detectBotMention(decrypted, text); + const attachments = decrypted + ? this.attachmentsFromDecrypted(conversationId, decrypted) + : []; + + return new Message({ + id: messageId ?? event.id, + threadId, + text, + formatted: parseMarkdown(text), + raw, + author: { + userId: senderId, + userName: senderId, + fullName: senderId, + isBot: false, + isMe: senderId === this.userId, + }, + metadata: { + dateSent: createdAtMsec ? new Date(createdAtMsec) : new Date(), + edited: isEdit, + }, + attachments, + ...(isMention ? { isMention: true } : {}), + }); + } + + /** + * Mention decision from the decrypted payload. + * + * Structured mention entities are authoritative when present; a + * swipe-reply to one of the bot's messages counts as a mention; otherwise + * undefined so the Chat SDK's plain-text `@handle` fallback applies. + */ + private detectBotMention( + decrypted: XchatDecryptedEvent | null, + text: string + ): boolean | undefined { + if (!decrypted) { + return undefined; + } + const content = decrypted.content; + const preview = replyPreviewOf(content); + // A reply preview that failed chat-xdk's validation against its embedded + // raw source event is forged and must not trigger the bot. + const previewTrusted = decrypted.replyPreviewValidation !== "invalid"; + const isReplyToBot = Boolean( + previewTrusted && preview?.senderId && preview.senderId === this.userId + ); + + const handles = mentionHandlesFromEntities(text, content?.entities); + if (handles.length > 0) { + const targets = new Set([ + this._userName.replace(LEADING_AT_RE, "").toLowerCase(), + this.userId.toLowerCase(), + ]); + if (handles.some((h) => targets.has(h))) { + return true; + } + return isReplyToBot || undefined; + } + + return isReplyToBot || undefined; + } + + /** Map media references on a decrypted event to Chat SDK attachments. */ + private attachmentsFromDecrypted( + conversationId: string, + decrypted: XchatDecryptedEvent + ): Attachment[] { + const keyVersion = decrypted.keyVersion; + return extractMediaEntries(decrypted).map((entry) => ({ + type: chatAttachmentType(entry.mediaType), + name: entry.filename, + size: Number.isFinite(entry.filesize) ? entry.filesize : undefined, + width: entry.width, + height: entry.height, + fetchData: () => + this.fetchMediaAttachment(conversationId, entry.hashKey, keyVersion), + })); + } + + /** Insert into the bounded messageId ⇄ sequenceId caches. */ + private rememberSequenceId(messageId: string, sequenceId: string): void { + if (this.sequenceIdByMessageId.size >= SEQUENCE_CACHE_MAX) { + const oldest = this.sequenceIdByMessageId.keys().next().value; + if (oldest !== undefined) { + const oldestSequenceId = this.sequenceIdByMessageId.get(oldest); + this.sequenceIdByMessageId.delete(oldest); + if (oldestSequenceId !== undefined) { + this.messageIdBySequenceId.delete(oldestSequenceId); + } + } + } + this.sequenceIdByMessageId.set(messageId, sequenceId); + this.messageIdBySequenceId.set(sequenceId, messageId); + } + + // ── Post / Edit / Delete ────────────────────────────────────────── + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { conversationId } = this.decodeThreadId(threadId); + // Cards degrade to text plus a URL preview attachment: XChat has no + // interactive primitives, so links become entities and the primary link + // becomes the preview card. + const card = cardOf(message); + const rendered = card ? cardToXChat(card) : null; + const text = rendered ? rendered.text : this.renderFormatted(message); + const crypto = this.getCryptoEngine(); + + this.stopTyping(conversationId); + + // Get conversation key — auto-fetch if missing + let keyInfo = this.getLatestKey(conversationId); + if (!keyInfo) { + this.logger.debug( + "No cached key, fetching conversation to extract keys", + { + conversationId, + } + ); + await this.fetchMessages(threadId, { limit: 1 }); + keyInfo = this.getLatestKey(conversationId); + if (!keyInfo) { + throw new Error( + `No conversation key for ${conversationId} even after fetching.` + ); + } + } + + // chat-xdk canonicalizes conversation id for signatures + if (!this.signingKeyVersion) { + throw new Error( + "signingKeyVersion not available. Was initialize() called?" + ); + } + + // Upload any file/attachment payloads (encrypt-stream + 3-step upload) + const mediaAttachments = await this.uploadOutgoingMedia( + conversationId, + keyInfo, + message + ); + + // Rich-text entities (tappable links / mention pills). A message carries + // one attachment kind, preferred in order: uploaded media, the card's URL + // preview, then the first x.com post card referenced in the text. + const entities = detectEntities(text); + let attachments: AttachmentDescriptor[] | null; + if (mediaAttachments.length > 0) { + attachments = mediaAttachments; + } else if (rendered?.urlCard) { + attachments = [ + await this.urlCardAttachment(conversationId, keyInfo, rendered.urlCard), + ]; + } else { + attachments = extractPostAttachments(text)?.slice(0, 1) ?? null; + } + + // Group chats reply-quote the triggering message; replies inherit its TTL. + const inbound = this.lastInboundByConv.get(conversationId); + const isGroup = conversationId.startsWith("g"); + const ttlMsec = inbound?.ttlMsec ?? null; + + const common = { + senderId: this.userId, + conversationId, + conversationKey: keyInfo.key, + text, + conversationKeyVersion: keyInfo.version, + signingKeyVersion: this.signingKeyVersion, + entities, + attachments, + ttlMsec, + }; + const payload = + isGroup && inbound?.sequenceId + ? this.encryptGroupReply(crypto, common, inbound) + : crypto.encryptMessage(common); + + // The SDK mints the message id and binds it into the signature. + const messageId = payload.messageId; + + await this.sendEncryptedPayload(conversationId, messageId, payload); + // Age gates the first edit of this message (bounded alongside the + // sequence-id cache). + if (this.postedAtByMessageId.size >= SEQUENCE_CACHE_MAX) { + const oldest = this.postedAtByMessageId.keys().next().value; + if (oldest !== undefined) { + this.postedAtByMessageId.delete(oldest); + } + } + this.postedAtByMessageId.set(messageId, Date.now()); + + // Build a synthetic raw message for the return + const rawMessage: XchatRawMessage = { + event: { + id: messageId, + conversationId, + senderId: this.userId, + encodedEvent: payload.encryptedContent, + createdAtMsec: String(Date.now()), + }, + decrypted: { + type: "message", + id: messageId, + senderId: this.userId, + conversationId, + createdAtMsec: Date.now(), + content: { text, contentType: "Text" }, + verified: true, + }, + }; + + return { + id: messageId, + raw: rawMessage, + threadId, + }; + } + + /** + * Encrypt a group quote-reply, preferring the event-based form. + * + * Passing the raw signed event (`replyToEvent`) lets chat-xdk derive the + * preview from the original — including the edited contents when the + * original was edited — and embed the raw event so recipients can + * validate the preview. When the original can't be parsed or decrypted + * (e.g. it was encrypted under a key version we don't hold), fall back + * to the explicit preview overrides, which skip the embedding. + */ + private encryptGroupReply( + crypto: ChatWithJuicebox, + common: EncryptMessageParams, + inbound: InboundContext + ): SendPayload { + if (inbound.encodedEvent) { + try { + return crypto.encryptReply({ + ...common, + replyToEvent: inbound.encodedEvent, + }); + } catch (err) { + this.logger.debug( + "encryptReply(replyToEvent) failed; using preview overrides", + { error: err instanceof Error ? err.message : String(err) } + ); + } + } + return crypto.encryptReply({ + ...common, + replyToSequenceId: inbound.sequenceId, + replyToSenderId: inbound.senderId ?? null, + replyToText: inbound.text ?? null, + }); + } + + /** POST an encrypted payload to the send-message endpoint. */ + private async sendEncryptedPayload( + conversationId: string, + messageId: string, + payload: { encryptedContent: string; encodedEventSignature: string } + ): Promise { + const client = this.getXdkClient(); + const apiConvId = conversationPathId(conversationId, this.userId); + const convToken = this.conversationTokens.get(conversationId); + const response = (await client.chat.sendMessage(apiConvId, { + messageId, + encodedMessageCreateEvent: payload.encryptedContent, + encodedMessageEventSignature: payload.encodedEventSignature, + ...(convToken ? { conversationToken: convToken } : {}), + })) as XApiResponse<{ + encodedMessageEvent?: string; + encoded_message_event?: string; + }>; + + // The response echoes the stored event; its sequence id is what edits, + // deletes, and reactions target, so capture it while it is cheap. A + // failed parse only costs the cache entry — resolveSequenceId falls + // back to a history fetch. + const encodedEvent: unknown = + response?.data?.encodedMessageEvent ?? + response?.data?.encoded_message_event; + if (typeof encodedEvent === "string" && encodedEvent.length > 0) { + try { + const crypto = this.getCryptoEngine(); + const keyInfo = this.getLatestKey(conversationId); + const event = crypto.decryptEvent( + encodedEvent, + keyInfo ? { [keyInfo.version]: keyInfo.key } : null, + this.signingKeyCache.get(this.userId) ?? [] + ) as { sequenceId?: unknown; message?: { sequenceId?: unknown } }; + const sequenceId = event?.sequenceId ?? event?.message?.sequenceId; + if (typeof sequenceId === "string" && sequenceId.length > 0) { + this.rememberSequenceId(messageId, sequenceId); + } + } catch { + // Undecodable echo — the id stays resolvable via fetchMessages. + } + } + } + + /** + * Resolve a message's sequence id (the identifier edits, deletes, and + * reactions target). Falls back to replaying recent history, which + * repopulates the cache through parseMessage. + */ + private async resolveSequenceId( + threadId: string, + messageId: string + ): Promise { + const cached = this.sequenceIdByMessageId.get(messageId); + if (cached) { + return cached; + } + await this.fetchMessages(threadId, { limit: 50 }); + const fetched = this.sequenceIdByMessageId.get(messageId); + if (!fetched) { + throw new Error( + `No sequence id known for message ${messageId}; it may be older than the last 50 events.` + ); + } + return fetched; + } + + /** + * Encrypt-stream + upload every file / data-bearing attachment on an + * outgoing message, returning media attachment descriptors. + */ + private async uploadOutgoingMedia( + conversationId: string, + keyInfo: { key: Uint8Array; version: string }, + message: AdapterPostableMessage + ): Promise { + if (typeof message === "string") { + return []; + } + const files: FileUpload[] = Array.isArray( + (message as { files?: FileUpload[] }).files + ) + ? ((message as { files?: FileUpload[] }).files as FileUpload[]) + : []; + const withData: Attachment[] = ( + Array.isArray((message as { attachments?: Attachment[] }).attachments) + ? ((message as { attachments?: Attachment[] }) + .attachments as Attachment[]) + : [] + ).filter((a) => a.data || a.fetchData); + + const descriptors: AttachmentDescriptor[] = []; + for (const file of files) { + const bytes = await toBytes(file.data); + descriptors.push( + await this.encryptAndUploadMedia(conversationId, keyInfo, bytes, { + filename: file.filename, + mimeType: file.mimeType, + }) + ); + } + for (const att of withData) { + const data = att.data ?? (await att.fetchData?.()); + if (!data) { + continue; + } + const bytes = await toBytes(data); + descriptors.push( + await this.encryptAndUploadMedia(conversationId, keyInfo, bytes, { + filename: att.name ?? "attachment", + mimeType: att.mimeType, + width: att.width, + height: att.height, + }) + ); + } + return descriptors; + } + + /** + * Edit a previously sent message. The edit is an encrypted event carrying + * the target's sequence id plus the replacement text and entities; it is + * sent through the same channel as a regular message and recipients apply + * it to the original in place. Only the bot's own text messages are + * editable. + */ + async editMessage( + threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + const { conversationId } = this.decodeThreadId(threadId); + const crypto = this.getCryptoEngine(); + if (!this.signingKeyVersion) { + throw new Error( + "signingKeyVersion not available. Was initialize() called?" + ); + } + const sequenceId = await this.resolveSequenceId(threadId, messageId); + const keyInfo = this.getLatestKey(conversationId); + if (!keyInfo) { + throw new Error(`No conversation key for ${conversationId}`); + } + + // Hold the edit until the original is old enough to have been delivered: + // an edit that reaches a client before the original leaves the message + // permanently invisible there (the backend stops serving superseded + // originals, so the client can never backfill the edit's target). + const postedAt = this.postedAtByMessageId.get(messageId); + if (postedAt !== undefined && this.editSafetyDelayMs > 0) { + const remaining = this.editSafetyDelayMs - (Date.now() - postedAt); + if (remaining > 0) { + this.logger.debug("Delaying first edit until the original settles", { + messageId, + remainingMs: remaining, + }); + await new Promise((resolveWait) => setTimeout(resolveWait, remaining)); + } + } + + const text = this.renderFormatted(message); + const payload = crypto.encryptEdit({ + senderId: this.userId, + conversationId, + targetMessageSequenceId: sequenceId, + updatedText: text, + entities: detectEntities(text), + conversationKey: keyInfo.key, + conversationKeyVersion: keyInfo.version, + signingKeyVersion: this.signingKeyVersion, + }); + await this.sendEncryptedPayload(conversationId, payload.messageId, payload); + + // The edit event has its own id; the returned message keeps the edited + // message's id so callers can keep referencing (and re-editing) it. + return { + id: messageId, + threadId, + raw: { + event: { + id: payload.messageId, + conversationId, + senderId: this.userId, + encodedEvent: payload.encryptedContent, + createdAtMsec: String(Date.now()), + }, + decrypted: { + type: "message", + id: messageId, + senderId: this.userId, + conversationId, + createdAtMsec: Date.now(), + content: { text, contentType: "Text" }, + verified: true, + }, + }, + }; + } + + /** + * Delete a message for every participant. The signed delete action is + * prepared locally (recipients verify it) and submitted with the request; + * the API accepts delete-for-all only for the bot's own messages. + */ + async deleteMessage(threadId: string, messageId: string): Promise { + const { conversationId } = this.decodeThreadId(threadId); + const crypto = this.getCryptoEngine(); + if (!this.signingKeyVersion) { + throw new Error( + "signingKeyVersion not available. Was initialize() called?" + ); + } + const sequenceId = await this.resolveSequenceId(threadId, messageId); + const signature = crypto.prepareMessageDelete({ + senderId: this.userId, + conversationId, + sequenceIds: [sequenceId], + deleteForAll: true, + signingKeyVersion: this.signingKeyVersion, + }); + + const client = this.getXdkClient(); + await client.chat.deleteMessages(dashConversationId(conversationId), { + sequenceIds: [sequenceId], + deleteMessageAction: "delete_for_all", + actionSignatures: [ + { + messageId: signature.messageId, + encodedMessageEventDetail: signature.encodedMessageEventDetail, + ...(signature.signaturePayload + ? { signaturePayload: signature.signaturePayload } + : {}), + messageEventSignature: { + signature: signature.signature, + publicKeyVersion: signature.publicKeyVersion, + signatureVersion: signature.signatureVersion, + signingPublicKey: crypto.getPublicKeys().signing, + }, + }, + ], + }); + this.logger.info("Deleted message", { conversationId, sequenceId }); + } + + // ── Reactions ───────────────────────────────────────────────────── + + async addReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + await this.sendReaction(threadId, messageId, emoji, true); + } + + async removeReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + await this.sendReaction(threadId, messageId, emoji, false); + } + + private async sendReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string, + add: boolean + ): Promise { + const { conversationId } = this.decodeThreadId(threadId); + // Reactions are best-effort: when the sequence id is neither cached nor + // recoverable from recent history, warn and skip instead of throwing. + let sequenceId: string; + try { + sequenceId = await this.resolveSequenceId(threadId, messageId); + } catch (err) { + this.logger.warn("Cannot react: no sequence id for message", { + threadId, + messageId, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const crypto = this.getCryptoEngine(); + const keyInfo = this.getLatestKey(conversationId); + if (!keyInfo) { + throw new Error(`No conversation key for ${conversationId}`); + } + if (!this.signingKeyVersion) { + throw new Error( + "signingKeyVersion not available. Was initialize() called?" + ); + } + + // Resolve to a unicode emoji (X Chat reactions are unicode). The shared + // resolver's Google Chat profile is its plain-unicode output, so it is + // the right conversion here despite the name. + const unicodeEmoji = defaultEmojiResolver.toGChat(emoji); + + const params = { + senderId: this.userId, + conversationId, + conversationKey: keyInfo.key, + targetMessageSequenceId: sequenceId, + emoji: unicodeEmoji, + conversationKeyVersion: keyInfo.version, + signingKeyVersion: this.signingKeyVersion, + }; + const payload = add + ? crypto.encryptAddReaction(params) + : crypto.encryptRemoveReaction(params); + + // The SDK mints the reaction's message id and binds it into the signature. + await this.sendEncryptedPayload(conversationId, payload.messageId, payload); + } + + // ── Fetch messages ──────────────────────────────────────────────── + + async fetchMessages( + threadId: string, + options?: FetchOptions + ): Promise> { + const { conversationId } = this.decodeThreadId(threadId); + const client = this.getXdkClient(); + const crypto = this.getCryptoEngine(); + + // Conversation events come from GET /2/chat/conversations/{id}/events + // (GET /2/chat/conversations/{id} returns only conversation metadata). + // Conversation key (KeyChange) events arrive inline in data[] — not in + // meta — so we hand the whole batch to decryptEvents(), which extracts + // keys and decrypts messages in a single pass. + const apiConvId = conversationPathId(conversationId, this.userId); + const response = (await client.chat.getConversationEvents(apiConvId, { + maxResults: options?.limit ?? 50, + paginationToken: options?.cursor, + chatMessageEventFields: [ + ...MESSAGE_EVENT_FIELDS, + ] as unknown as MessageEventFields, + })) as unknown as ConversationEventsResponse; + + const rawEvents = response?.data ?? []; + const nextCursor = response?.meta?.nextToken ?? response?.meta?.next_token; + + // Cache keys/tokens under the conversationId on events (colon form), not + // the URL/list form — that is the only id decrypt paths will look up. + const cacheConvId = + rawEvents.find((e) => e.conversationId)?.conversationId ?? conversationId; + + // Collect encoded events, cache tokens, and map back to raw metadata. + const encodedEvents: string[] = []; + const b64ToRaw = new Map>(); + const senderIds = new Set(); + for (const evt of rawEvents) { + if (evt.conversationToken) { + this.conversationTokens.set( + evt.conversationId ?? cacheConvId, + evt.conversationToken + ); + } + if (evt.senderId) { + senderIds.add(evt.senderId); + } + const encodedEvent = evt.encodedEvent ?? ""; + if (encodedEvent) { + encodedEvents.push(encodedEvent); + b64ToRaw.set(encodedEvent, evt); + } + } + + if (encodedEvents.length === 0) { + return { messages: [], nextCursor }; + } + + // Fetch signing keys for everyone involved so decryptEvents can verify + // message signatures (verification is best-effort — missing keys just + // leave messages unverified rather than failing decryption). + const involvedUserIds = new Set([ + ...senderIds, + this.userId, + ...this.participantsFromConversationId(cacheConvId), + ]); + const signingKeys = await this.getSigningKeysForUsers([...involvedUserIds]); + + // Batch decrypt — extracts conversation keys from KeyChange events, + // matches signing keys by userId, and decrypts every message in one pass. + let result: ReturnType; + try { + result = crypto.decryptEvents(encodedEvents, signingKeys); + } catch (err) { + this.logger.warn("decryptEvents failed", { + conversationId: cacheConvId, + error: err, + }); + return { messages: [], nextCursor }; + } + + // Merge extracted conversation keys into the cache (used for sending). + this.mergeConversationKeys(cacheConvId, result.conversationKeys); + + // Under the SDK's hardened default (reject_unverified = true) any event + // whose signature can't be verified lands here instead of in messages. + // Surface it so silent drops are debuggable. + const errorCount = Object.keys(result.errors).length; + if (errorCount > 0) { + this.logger.debug("decryptEvents reported errors", { + conversationId: cacheConvId, + errorCount, + errors: result.errors, + }); + } + + const messages: Message[] = []; + for (const dm of result.messages) { + const event = dm.event; + if ( + !event || + processableText(event as unknown as XchatDecryptedEvent) === null + ) { + continue; + } + + const raw = dm.originalB64 ? b64ToRaw.get(dm.originalB64) : undefined; + // The decrypted event's meta carries epoch millis; the API event carries + // an ISO-8601 created_at instead. + const rawCreatedAtMsec = + raw?.createdAtMsec ?? + (raw?.createdAt ? Date.parse(raw.createdAt) : Number.NaN); + const createdAtMsec = + event.createdAtMsec ?? + (Number.isFinite(Number(rawCreatedAtMsec)) + ? rawCreatedAtMsec + : undefined); + const rawMessage: XchatRawMessage = { + event: { + id: event.id ?? raw?.id ?? "", + conversationId, + senderId: event.senderId ?? raw?.senderId ?? "", + encodedEvent: dm.originalB64 ?? raw?.encodedEvent ?? "", + createdAtMsec: + createdAtMsec == null ? undefined : String(createdAtMsec), + conversationToken: raw?.conversationToken, + messageEventSignature: raw?.messageEventSignature, + sequenceId: event.sequenceId ?? raw?.sequenceId, + }, + decrypted: event as XchatDecryptedEvent, + }; + messages.push(this.parseMessage(rawMessage)); + } + + // Sort chronologically (oldest first) + messages.sort( + (a, b) => a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime() + ); + + return { messages, nextCursor }; + } + + async fetchThread(threadId: string): Promise { + const { conversationId } = this.decodeThreadId(threadId); + return { + id: threadId, + channelId: threadId, + isDM: !conversationId.startsWith("g"), + metadata: { conversationId }, + }; + } + + // ── Format rendering ────────────────────────────────────────────── + + renderFormatted(content: FormattedContent | AdapterPostableMessage): string { + if (typeof content === "string") { + return content; + } + if ("type" in content && content.type === "root") { + return this.formatConverter.fromAst(content as FormattedContent); + } + return this.formatConverter.renderPostable( + content as AdapterPostableMessage + ); + } + + // ── Typing indicator ────────────────────────────────────────────── + + /** + * Send a typing indicator and keep re-sending it every few seconds until + * the next postMessage in this conversation (or a safety timeout). X Chat + * typing pills expire quickly, so a single POST is invisible during a + * longer generation. + */ + async startTyping(threadId: string): Promise { + const { conversationId } = this.decodeThreadId(threadId); + this.stopTyping(conversationId); + await this.sendTypingOnce(conversationId); + + const startedAt = Date.now(); + const timer = setInterval(() => { + if (Date.now() - startedAt > TYPING_MAX_MS) { + this.stopTyping(conversationId); + return; + } + this.sendTypingOnce(conversationId).catch(() => undefined); + }, TYPING_INTERVAL_MS); + timer.unref?.(); + this.typingTimers.set(conversationId, timer); + } + + private async sendTypingOnce(conversationId: string): Promise { + try { + const client = this.getXdkClient(); + const apiConvId = conversationPathId(conversationId, this.userId); + await client.chat.sendTypingIndicator(apiConvId); + } catch (err) { + this.logger.warn("Failed to send typing indicator", { error: err }); + } + } + + private stopTyping(conversationId: string): void { + const timer = this.typingTimers.get(conversationId); + if (timer) { + clearInterval(timer); + this.typingTimers.delete(conversationId); + } + } + + // ── Read receipts ───────────────────────────────────────────────── + + /** + * Send a read receipt (seen-until watermark) for an inbound message. + * + * Called automatically for each delivered inbound message when + * `sendReadReceipts` is enabled (the default). Failures are logged and + * swallowed so message delivery is never blocked by a receipt error. + */ + async markAsRead(threadId: string, message: Message): Promise { + const { conversationId } = this.decodeThreadId(threadId); + const raw = message.raw as XchatRawMessage | undefined; + let sequenceId = raw?.decrypted?.sequenceId ?? raw?.event?.sequenceId ?? ""; + + try { + const client = this.getXdkClient(); + const apiConvId = conversationPathId(conversationId, this.userId); + + // Fallback: mark read up to the latest event in the conversation. + if (!sequenceId) { + const response = (await client.chat.getConversationEvents(apiConvId, { + maxResults: 1, + chatMessageEventFields: [ + ...MESSAGE_EVENT_FIELDS, + ] as unknown as MessageEventFields, + })) as unknown as ConversationEventsResponse; + const latest = response?.data?.[0]; + // Event ids are a different namespace — only a real sequence id is a + // valid read watermark, so skip when the latest event lacks one. + sequenceId = latest?.sequenceId ?? ""; + if (!sequenceId) { + this.logger.debug("markAsRead skipped: no sequenceId available", { + messageId: message.id, + threadId, + }); + return; + } + } + + await client.chat.markConversationRead(apiConvId, { + seenUntilSequenceId: sequenceId, + }); + this.logger.debug("Read receipt sent", { + conversationId: apiConvId, + sequenceId, + }); + } catch (err) { + this.logger.warn("Failed to send read receipt", { + threadId, + messageId: message.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // ── DM detection ────────────────────────────────────────────────── + + isDM(threadId: string): boolean { + const { conversationId } = this.decodeThreadId(threadId); + return !conversationId.startsWith("g"); + } + + // ── Media (encrypted download / upload) ────────────────────────── + + /** + * Download and decrypt a media attachment. + * + * Media bytes are secretstream-encrypted under a conversation key; + * `keyVersion` selects the matching cached key (latest when omitted). + */ + async fetchMediaAttachment( + conversationId: string, + mediaHashKey: string, + keyVersion?: string + ): Promise { + const crypto = this.getCryptoEngine(); + + let keyResult = this.conversationKeys.get(conversationId); + if (!keyResult) { + await this.fetchMessages(this.encodeThreadId({ conversationId }), { + limit: 1, + }); + keyResult = this.conversationKeys.get(conversationId); + } + const key = + (keyVersion ? keyResult?.keys[keyVersion] : undefined) ?? + (keyResult?.latestVersion + ? keyResult.keys[keyResult.latestVersion] + : undefined); + if (!key) { + throw new Error(`No conversation key for ${conversationId}`); + } + + const client = this.getXdkClient(); + // The media routes accept only the dash-joined participant pair (or the + // g-prefixed group id) — not the bare peer id the other chat routes take, + // and not the colon form carried in events. + const encrypted = new Uint8Array( + await client.chat.mediaDownload( + dashConversationId(conversationId), + mediaHashKey + ) + ); + const plaintext = crypto.decryptStream(encrypted, key); + return Buffer.from(plaintext); + } + + /** + * Encrypt-stream a payload and run the 3-step upload (initialize / append / + * finalize), returning the stored blob's media hash key. + */ + private async uploadEncryptedBlob( + conversationId: string, + keyInfo: { key: Uint8Array; version: string }, + bytes: Uint8Array + ): Promise<{ mediaHashKey: string; encryptedBytes: number }> { + const crypto = this.getCryptoEngine(); + const client = this.getXdkClient(); + const encrypted = crypto.encryptStream(bytes, keyInfo.key); + + // Media routes accept only the dash-joined participant pair (or the + // g-prefixed group id), never the colon-joined internal form. + const apiConversationId = dashConversationId(conversationId); + + const initBody = await client.chat.mediaUploadInitialize({ + conversationId: apiConversationId, + totalBytes: encrypted.length, + }); + const sessionId = initBody.data?.sessionId; + const mediaHashKey = initBody.data?.mediaHashKey; + if (!(sessionId && mediaHashKey)) { + throw new Error("Media upload initialize returned no session"); + } + + let segmentIndex = 0; + for ( + let offset = 0; + offset < encrypted.length; + offset += UPLOAD_CHUNK_BYTES + ) { + const chunk = encrypted.subarray(offset, offset + UPLOAD_CHUNK_BYTES); + await client.chat.mediaUploadAppend(sessionId, { + conversationId: apiConversationId, + mediaHashKey, + segmentIndex, + media: Buffer.from(chunk).toString("base64"), + }); + segmentIndex += 1; + } + + await client.chat.mediaUploadFinalize(sessionId, { + conversationId: apiConversationId, + mediaHashKey, + numParts: String(segmentIndex), + }); + + return { mediaHashKey, encryptedBytes: encrypted.length }; + } + + private async encryptAndUploadMedia( + conversationId: string, + keyInfo: { key: Uint8Array; version: string }, + bytes: Uint8Array, + meta: { + filename: string; + mimeType?: string; + width?: number; + height?: number; + } + ): Promise { + const mimeType = meta.mimeType ?? detectMimeType(bytes) ?? ""; + const { mediaHashKey } = await this.uploadEncryptedBlob( + conversationId, + keyInfo, + bytes + ); + + // Dimensions from the plaintext; videos fall back to a 16:9 default so + // the client renders a usable preview card. + const isVideo = mimeType.startsWith("video/"); + const dims = detectImageDimensions(bytes); + const width = meta.width ?? dims?.width ?? (isVideo ? 1280 : 1024); + const height = meta.height ?? dims?.height ?? (isVideo ? 720 : 1024); + const mediaType = outgoingMediaType(mimeType); + + this.logger.debug("Media uploaded", { + conversationId, + mediaHashKey: mediaHashKey.slice(0, 16), + bytes: bytes.length, + mimeType, + }); + + return { + attachment_type: "media", + media_hash_key: mediaHashKey, + width, + height, + filesize_bytes: bytes.length, + filename: meta.filename, + ...(mediaType === undefined ? {} : { media_type: mediaType }), + }; + } + + /** + * Build a URL preview attachment from a card spec, encrypting and + * uploading the banner image when the spec carries one. The banner's + * hash key, encrypted size, and filename are all required on the wire — + * receiving clients silently discard the image when any is missing — so + * a failed banner fetch degrades to a card without an image. + */ + private async urlCardAttachment( + conversationId: string, + keyInfo: { key: Uint8Array; version: string }, + spec: XchatUrlCardSpec + ): Promise { + let bannerImage: UrlAttachmentImageDescriptor | undefined; + if (spec.imageUrl) { + try { + const response = await fetch(spec.imageUrl); + if (response.ok) { + const bytes = new Uint8Array(await response.arrayBuffer()); + const { mediaHashKey, encryptedBytes } = + await this.uploadEncryptedBlob(conversationId, keyInfo, bytes); + const dims = detectImageDimensions(bytes); + const filename = + spec.imageUrl.split("/").pop()?.split("?")[0] || "banner"; + bannerImage = { + media_hash_key: mediaHashKey, + filesize_bytes: encryptedBytes, + filename, + ...(dims ? { width: dims.width, height: dims.height } : {}), + }; + } + } catch (err) { + this.logger.warn("URL card banner upload failed; sending without it", { + imageUrl: spec.imageUrl, + error: err instanceof Error ? err.message : String(err), + }); + } + } + return { + attachment_type: "url", + url: spec.url, + ...(spec.displayTitle ? { display_title: spec.displayTitle } : {}), + ...(bannerImage ? { banner_image: bannerImage } : {}), + }; + } + + // ── Open a DM ───────────────────────────────────────────────────── + + /** + * Start (or reopen) a 1:1 conversation with a user, returning its thread + * id. XChat 1:1 ids are derived from the participant pair, so an existing + * conversation is reused: when a conversation key is already held (or + * fetchable), no key exchange happens. Otherwise a fresh conversation key + * is prepared, encrypted to both participants' identity keys, and posted + * to initialize the conversation. Requires the recipient to have encrypted + * chat set up; the server also requires the recipient to trust the bot + * (e.g. follow it) before the first message is accepted. + */ + async openDM(userId: string): Promise { + if (!NUMERIC_USER_ID.test(userId)) { + throw new Error( + `openDM requires a numeric X user id (got "${userId}"). ` + + "Handles like @alice must be resolved to a user id first." + ); + } + const crypto = this.getCryptoEngine(); + const client = this.getXdkClient(); + + const canonicalId = [userId, this.userId] + .map(BigInt) + .sort((a, b) => { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; + }) + .map(String) + .join(":"); + const threadId = this.encodeThreadId({ conversationId: canonicalId }); + if (this.getLatestKey(canonicalId)) { + return threadId; + } + try { + await this.fetchMessages(threadId, { limit: 50 }); + } catch { + // No conversation yet — fall through to the key exchange. + } + if (this.getLatestKey(canonicalId)) { + return threadId; + } + + const signingEntries = await this.getSigningKeysForUsers([ + this.userId, + userId, + ]); + const publicKeys: PublicKeyInput[] = signingEntries.map((entry) => ({ + userId: entry.userId, + publicKey: entry.identityPublicKey, + keyVersion: entry.publicKeyVersion, + })); + if (!publicKeys.some((key) => key.userId === userId)) { + throw new Error( + `Cannot open DM: user ${userId} has no registered chat keys (encrypted chat not set up).` + ); + } + + const prepared = crypto.prepareConversationKeyChange({ publicKeys }); + const signingPublicKey = crypto.getPublicKeys().signing; + await client.chat.addConversationKeys( + dashConversationId(prepared.conversationId), + { + conversationKeyVersion: prepared.conversationKeyVersion, + conversationParticipantKeys: prepared.participantKeys.map((key) => ({ + userId: key.userId, + encryptedConversationKey: key.encryptedKey, + publicKeyVersion: key.publicKeyVersion, + })), + actionSignatures: prepared.actionSignatures.map((sig) => ({ + messageId: sig.messageId, + encodedMessageEventDetail: sig.encodedMessageEventDetail, + ...(sig.signaturePayload + ? { signaturePayload: sig.signaturePayload } + : {}), + messageEventSignature: { + signature: sig.signature, + signatureVersion: sig.signatureVersion, + publicKeyVersion: sig.publicKeyVersion, + signingPublicKey, + }, + })), + } + ); + + this.mergeConversationKeys(prepared.conversationId, { + keys: { [prepared.conversationKeyVersion]: prepared.conversationKey }, + latestVersion: prepared.conversationKeyVersion, + }); + this.logger.info("Opened DM conversation", { + conversationId: prepared.conversationId, + keyVersion: prepared.conversationKeyVersion, + }); + return this.encodeThreadId({ conversationId: prepared.conversationId }); + } + + // ── Internal helpers ────────────────────────────────────────────── + + private getXdkClient(): InstanceType { + if (!this.xdkClient) { + throw new Error( + "XChat adapter not initialized. Call initialize() first." + ); + } + return this.xdkClient; + } + + private getCryptoEngine(): ChatWithJuicebox { + if (!this.cryptoEngine || this._cryptoStatus !== "ready") { + throw new Error( + this._cryptoStatus === "locked" + ? "XChat adapter is locked. Call adapter.unlock(pin) first." + : "XChat crypto not initialized. Call initialize() first." + ); + } + return this.cryptoEngine; + } + + /** + * Derive the candidate participant user IDs from a conversation ID. + * + * 1:1 conversation IDs encode both participants as `{userId1}-{userId2}` + * (or `{userId1}:{userId2}`). Group conversation IDs are opaque (`g...`), + * so we return nothing and rely on the sender IDs seen in events. + */ + private participantsFromConversationId(conversationId: string): string[] { + if (conversationId.startsWith("g")) { + return []; + } + return conversationId + .split(CONVERSATION_ID_SEPARATOR) + .filter((part) => NUMERIC_USER_ID.test(part)); + } + + /** + * Fetch (and cache) signing keys for the given users from the X API. + * + * Returns a flat array of SigningKeyEntry (one per user per key version) + * suitable for `decryptEvents` / `decryptEvent`. Failures are swallowed — + * a missing signing key only means messages from that user stay unverified. + * Failed lookups are not cached, so a transient API error is retried on + * the next call instead of leaving the user permanently unverifiable. + */ + private async getSigningKeysForUsers( + userIds: string[] + ): Promise { + const client = this.getXdkClient(); + const missing = userIds.filter((id) => id && !this.signingKeyCache.has(id)); + + await Promise.all( + missing.map(async (userId) => { + try { + const response = (await client.users.getPublicKey(userId, { + publicKeyFields: [...SIGNING_KEY_FIELDS], + })) as XApiResponse; + const data = response?.data; + let keys: PublicKeyData[] = []; + if (Array.isArray(data)) { + keys = data; + } else if (data) { + keys = [data]; + } + + const entries: SigningKeyEntry[] = []; + for (const key of keys) { + const publicKeyVersion = publicKeyVersionOf(key); + if ( + publicKeyVersion && + key.signingPublicKey && + key.publicKey && + key.identityPublicKeySignature + ) { + entries.push({ + userId, + publicKeyVersion, + publicKey: key.signingPublicKey, + identityPublicKey: key.publicKey, + identityPublicKeySignature: key.identityPublicKeySignature, + }); + } + } + this.signingKeyCache.set(userId, entries); + } catch (err) { + // Deliberately not cached: the next lookup retries the fetch. + this.logger.debug("Failed to fetch signing keys", { + userId, + error: err, + }); + } + }) + ); + + const all: SigningKeyEntry[] = []; + for (const id of userIds) { + const cached = this.signingKeyCache.get(id); + if (cached) { + all.push(...cached); + } + } + return all; + } + + /** + * Merge freshly extracted conversation keys into the cache for a conversation. + */ + private mergeConversationKeys( + conversationId: string, + extracted: ConversationKeyResult + ): void { + if (!extracted || Object.keys(extracted.keys).length === 0) { + return; + } + const existing = this.conversationKeys.get(conversationId); + if (existing) { + for (const [version, key] of Object.entries(extracted.keys)) { + existing.keys[version] = key; + } + if (extracted.latestVersion) { + existing.latestVersion = extracted.latestVersion; + } + } else { + this.conversationKeys.set(conversationId, { + keys: { ...extracted.keys }, + latestVersion: extracted.latestVersion, + }); + } + } + + /** + * Decrypt a single event, returning a XchatRawMessage. + * Falls back gracefully if decryption fails (returns null decrypted). + */ + private tryDecryptEvent( + conversationId: string, + event: XchatEvent, + signingKeys: SigningKeyEntry[] = [] + ): XchatRawMessage { + const crypto = this.getCryptoEngine(); + const keyResult = this.conversationKeys.get(conversationId); + + if (!(keyResult && event.encodedEvent)) { + return { event, decrypted: null }; + } + + try { + const decrypted = crypto.decryptEvent( + event.encodedEvent, + keyResult.keys, + signingKeys + ) as XchatDecryptedEvent; + return { event, decrypted }; + } catch { + this.logger.debug("Failed to decrypt event", { + conversationId, + eventId: event.id, + }); + return { event, decrypted: null }; + } + } + + /** + * Decrypt an XAA or polled API event (key extraction from + * conversation_key_change_event, encrypted_conversation_key, or a + * KeyChange encoded as encoded_event), then parse into a Message. + * + * Falls back to decryptEvents when single-event decrypt fails — the + * conversation-events poll path often embeds key material that way. + */ + private async decryptAndParseEvent( + event: XchatEvent + ): Promise> { + const crypto = this.getCryptoEngine(); + const conversationId = event.conversationId; + + // Extract conversation key if a key change event is present + if (event.conversationKeyChangeEvent) { + const extracted = crypto.extractConversationKeys([ + event.conversationKeyChangeEvent, + ]); + this.mergeConversationKeys(conversationId, extracted); + } + + // Poll path: KeyChange may arrive as the encoded_event itself + if (event.encodedEvent) { + try { + const extracted = crypto.extractConversationKeys([event.encodedEvent]); + this.mergeConversationKeys(conversationId, extracted); + } catch { + // Not a key-change event — fine. + } + } + + // Also try the encryptedConversationKey field (XAA webhook path) + if ( + event.encryptedConversationKey && + event.conversationKeyVersion && + !this.conversationKeys.get(conversationId)?.keys[ + event.conversationKeyVersion + ] + ) { + try { + const rawKey = crypto.decryptConversationKey( + event.encryptedConversationKey + ); + const existing = this.conversationKeys.get(conversationId); + if (existing) { + existing.keys[event.conversationKeyVersion] = rawKey; + existing.latestVersion = event.conversationKeyVersion; + } else { + this.conversationKeys.set(conversationId, { + keys: { [event.conversationKeyVersion]: rawKey }, + latestVersion: event.conversationKeyVersion, + }); + } + } catch { + this.logger.debug("Failed to decrypt conversation key", { + conversationId, + }); + } + } + + if (event.conversationToken) { + this.conversationTokens.set(conversationId, event.conversationToken); + } + + // Fetch signing keys for the sender so the message signature can be verified. + const signingKeys = await this.getSigningKeysForUsers([ + event.senderId, + this.userId, + ]); + + const raw = this.tryDecryptEvent(conversationId, event, signingKeys); + if (raw.decrypted) { + return this.parseMessage(raw); + } + + // Fallback: batch decryptEvents (handles signed poll events + key cache) + try { + const result = crypto.decryptEvents([event.encodedEvent], signingKeys); + this.mergeConversationKeys(conversationId, result.conversationKeys); + + const dm = result.messages.find((m) => m.event?.type === "message"); + if (dm?.event) { + return this.parseMessage({ + event, + decrypted: dm.event as unknown as XchatDecryptedEvent, + }); + } + } catch { + // Fall through to undecrypted message + } + + return this.parseMessage({ event, decrypted: null }); + } + + /** + * Get the latest conversation key for a conversation. + */ + private getLatestKey( + conversationId: string + ): { key: Uint8Array; version: string } | null { + const result = this.conversationKeys.get(conversationId); + if (!result?.latestVersion) { + return null; + } + const key = result.keys[result.latestVersion]; + if (!key) { + return null; + } + return { key, version: result.latestVersion }; + } +} + +// ── Factory function ────────────────────────────────────────────────── + +/** + * Create an XChat adapter with configuration from env vars or explicit config. + * + * **Required** (via config or env vars): + * - `botToken` / `accessToken` / `XCHAT_BOT_TOKEN` / `X_ACCESS_TOKEN`: OAuth2 access token + * + * **Optional:** + * - `pin` / `XCHAT_PIN`: Juicebox PIN; when set, initialize() auto-unlocks + * - `signingKeyVersion` / `X_SIGNING_KEY_VERSION`: Override for signing key version. + * When omitted, fetched automatically from the X API during initialize(). + * - `userName` / `X_BOT_USERNAME`: Override @handle. When omitted, initialize() + * resolves it from GET /2/users/me. + * - `verifySignatures` / `X_VERIFY_SIGNATURES`: Require verifiable signatures + * on incoming messages (default true). Set false to decrypt unverifiable + * messages instead of dropping them. + */ +export function createXchatAdapter(config?: XchatAdapterConfig): XchatAdapter { + const logger = config?.logger ?? new ConsoleLogger("info").child("xchat"); + + const accessToken = + config?.botToken ?? + config?.accessToken ?? + process.env.XCHAT_BOT_TOKEN ?? + process.env.X_ACCESS_TOKEN; + if (!accessToken) { + throw new ValidationError( + "xchat", + "botToken/accessToken is required. Set XCHAT_BOT_TOKEN or X_ACCESS_TOKEN, or provide botToken/accessToken in config." + ); + } + + const consumerSecret = + config?.consumerSecret ?? process.env.X_CONSUMER_SECRET ?? undefined; + + const pin = config?.pin ?? process.env.XCHAT_PIN ?? undefined; + + const signingKeyVersion = + config?.signingKeyVersion ?? process.env.X_SIGNING_KEY_VERSION ?? undefined; + + const verifySignatures = + config?.verifySignatures ?? process.env.X_VERIFY_SIGNATURES !== "false"; + + // Prefer explicit config; env override; otherwise initialize() fills from /2/users/me + const userName = config?.userName ?? process.env.X_BOT_USERNAME; + + return new XchatAdapter({ + accessToken, + apiBaseUrl: config?.apiBaseUrl, + apiHeaders: config?.apiHeaders, + consumerSecret, + editSafetyDelayMs: config?.editSafetyDelayMs, + logger, + pin, + signingKeyVersion, + verifySignatures, + userName, + welcomeMessage: config?.welcomeMessage, + }); +} diff --git a/packages/adapter-xchat/src/markdown.test.ts b/packages/adapter-xchat/src/markdown.test.ts new file mode 100644 index 00000000..29cb4ee1 --- /dev/null +++ b/packages/adapter-xchat/src/markdown.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { XchatFormatConverter } from "./markdown"; + +/** Italic markers may stringify as either `*italic*` or `_italic_`. */ +const ITALIC_MARKERS_RE = /[*_]italic[*_]/; + +describe("XchatFormatConverter", () => { + const converter = new XchatFormatConverter(); + + describe("toAst", () => { + it("should parse plain text", () => { + const ast = converter.toAst("Hello world"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it("should parse bold (**text**)", () => { + const ast = converter.toAst("**bold text**"); + expect(ast.type).toBe("root"); + }); + + it("should parse italic (_text_)", () => { + const ast = converter.toAst("_italic text_"); + expect(ast.type).toBe("root"); + }); + + it("should parse code blocks", () => { + const ast = converter.toAst("```\ncode\n```"); + expect(ast.type).toBe("root"); + }); + + it("should parse lists", () => { + const ast = converter.toAst("- item 1\n- item 2\n- item 3"); + expect(ast.type).toBe("root"); + }); + }); + + describe("fromAst", () => { + it("should stringify a simple AST", () => { + const ast = converter.toAst("Hello world"); + const result = converter.fromAst(ast); + expect(result).toContain("Hello world"); + }); + + it("should preserve bold formatting markers", () => { + const ast = converter.toAst("**bold text**"); + const result = converter.fromAst(ast); + expect(result).toContain("**bold text**"); + }); + + it("should preserve italic formatting markers", () => { + const ast = converter.toAst("_italic_"); + const result = converter.fromAst(ast); + expect(result).toMatch(ITALIC_MARKERS_RE); + }); + + it("should convert GFM tables to ASCII code blocks", () => { + const ast = converter.toAst( + "| Name | Age |\n|------|-----|\n| Alice | 30 |" + ); + const result = converter.fromAst(ast); + expect(result).toContain("```"); + expect(result).toContain("Name"); + expect(result).toContain("Age"); + expect(result).toContain("Alice"); + expect(result).toContain("30"); + // The whole table is fenced as a code block. + expect(result.startsWith("```")).toBe(true); + expect(result.endsWith("```")).toBe(true); + }); + + it("should roundtrip a complex message", () => { + const input = "Hello **world**! Here is a list:\n\n- item 1\n- item 2"; + const ast = converter.toAst(input); + const result = converter.fromAst(ast); + expect(result).toContain("Hello"); + expect(result).toContain("world"); + expect(result).toContain("item 1"); + expect(result).toContain("item 2"); + }); + }); + + describe("renderPostable", () => { + it("should render a string message", () => { + expect(converter.renderPostable("Hello")).toBe("Hello"); + }); + + it("should render a raw message", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("should render a markdown message", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toContain("bold"); + }); + }); +}); diff --git a/packages/adapter-xchat/src/markdown.ts b/packages/adapter-xchat/src/markdown.ts new file mode 100644 index 00000000..bae165bf --- /dev/null +++ b/packages/adapter-xchat/src/markdown.ts @@ -0,0 +1,55 @@ +/** + * XChat-specific format conversion using AST-based parsing. + * + * XChat clients render messages as plain text — there is no markdown + * rendering. The converter passes markdown through unchanged, so markers + * like `**bold**` appear literally but stay readable as plain-text + * conventions; URLs and @mentions are made tappable separately via + * entity spans. + * + * Tables are the one exception: raw GFM table syntax is unreadable as + * plain text, so they are converted to ASCII art in code blocks. + */ + +import { + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class XchatFormatConverter extends BaseFormatConverter { + /** + * Convert an AST to the text sent in an XChat message. + * + * Markdown is stringified as-is and shows up literally in clients, with + * one exception: tables are converted to ASCII art in code blocks since + * raw GFM table syntax is unreadable as plain text. + */ + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + // Tables -> code blocks (ASCII art) + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + + return stringifyMarkdown(transformed).trim(); + } + + /** + * Parse standard markdown into an AST. + */ + toAst(markdown: string): Root { + return parseMarkdown(markdown); + } +} diff --git a/packages/adapter-xchat/src/test-utils.ts b/packages/adapter-xchat/src/test-utils.ts new file mode 100644 index 00000000..9ba938f2 --- /dev/null +++ b/packages/adapter-xchat/src/test-utils.ts @@ -0,0 +1,257 @@ +/** + * Test utilities for the XChat adapter. + * + * Provides helpers for creating test adapters with real chat-xdk WASM crypto + * using the deterministic sdk_vectors.json fixture keys. + * + * Fixture crypto loads the wasm `Chat` directly from the package's `pkg/` + * build, because the public JS API exposes only the Juicebox-backed + * `createChat()` and cannot import raw fixture keys. + */ + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { ChatWithJuicebox } from "@xdevplatform/chat-xdk"; +import type { ChatInstance, Logger } from "chat"; +import { vi } from "vitest"; +import { XchatAdapter } from "./index"; +import type { XchatAdapterConfig } from "./types"; + +// ── Fixture data ──────────────────────────────────────────────────── + +const VECTORS_PATH = resolve( + fileURLToPath(import.meta.url), + "../../tests/fixtures/sdk_vectors.json" +); + +export interface SdkVectors { + conversation_key_b64: string; + event_conversation_id: string; + event_conversation_key_version: string; + event_message_b64: string; + event_message_text: string; + event_sender_id: string; + event_signing_key_version: string; + identity_private_b64: string; + identity_public_b64: string; + identity_public_key_signature_b64: string; + message_utf8: string; + plaintext_b64: string; + private_keys_concat_b64: string; + signature_b64: string; + signing_private_b64: string; + signing_public_b64: string; +} + +export function loadVectors(): SdkVectors { + return JSON.parse(readFileSync(VECTORS_PATH, "utf-8")); +} + +function b64ToBytes(b64: string): Uint8Array { + return Uint8Array.from(Buffer.from(b64, "base64")); +} + +export { b64ToBytes }; + +// ── Test constants ────────────────────────────────────────────────── + +export const TEST_USER_ID = "12345"; +export const TEST_OTHER_USER_ID = "67890"; +export const TEST_CONVERSATION_ID = `${TEST_USER_ID}-${TEST_OTHER_USER_ID}`; +export const TEST_CANONICAL_CONVERSATION_ID = `${TEST_USER_ID}:${TEST_OTHER_USER_ID}`; +export const TEST_THREAD_ID = `xchat:${TEST_CONVERSATION_ID}`; +const TEST_SIGNING_KEY_VERSION = "1"; +export const TEST_PIN = "2580"; + +// ── Mock logger ───────────────────────────────────────────────────── + +export const mockLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => mockLogger, +}; + +// ── Mock ChatInstance ─────────────────────────────────────────────── + +export function createMockChatInstance(): Record { + return { + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalSubmit: vi.fn(), + processModalClose: vi.fn(), + processSlashCommand: vi.fn(), + processMemberJoinedChannel: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + getState: vi.fn(), + getUserName: () => "test-bot", + getLogger: () => mockLogger, + handleIncomingMessage: vi.fn(), + }; +} + +// ── Wasm Chat loaded from pkg/ ────────────────────────────────────── + +interface WasmChat { + decryptConversationKey: (...args: unknown[]) => unknown; + decryptEvent: (...args: unknown[]) => unknown; + decryptEvents: (...args: unknown[]) => unknown; + encryptMessage(params: unknown): { + messageId: string; + encryptedContent: string; + encodedEventSignature: string; + }; + extractConversationKeys: (...args: unknown[]) => unknown; + getPublicKeys(): unknown; + importKeys(keys: Uint8Array, version?: string): void; + prepareConversationKeyChange(params: unknown): { + participantKeys: Array<{ userId: string; encryptedKey: string }>; + }; + setIdentity(userId: string, signingKeyVersion: string): void; + setRejectUnverified(reject: boolean): void; +} + +async function loadWasmChat(): Promise { + if (typeof globalThis.crypto === "undefined") { + const { webcrypto } = await import("node:crypto"); + (globalThis as { crypto: Crypto }).crypto = webcrypto as Crypto; + } + + const require = createRequire(import.meta.url); + const entry = require.resolve("@xdevplatform/chat-xdk"); + const pkgDir = dirname(entry); + const wasmMod = await import( + pathToFileURL(join(pkgDir, "pkg/chat_xdk_wasm.js")).href + ); + const init = wasmMod.default || wasmMod.init || wasmMod.__wbindgen_init; + if (typeof init !== "function") { + throw new Error("WASM init function not found in chat_xdk_wasm"); + } + const wasmBytes = readFileSync(join(pkgDir, "pkg/chat_xdk_wasm_bg.wasm")); + await init({ module_or_path: wasmBytes }); + return new wasmMod.Chat() as WasmChat; +} + +/** + * Real wasm crypto engine loaded with fixture keys (not via public createChat). + */ +export async function createTestCryptoEngine(): Promise { + const vectors = loadVectors(); + const chat = await loadWasmChat(); + // The version-aware import records the registered key version; the session + // identity supplies senderId + signingKeyVersion defaults for encryption. + chat.importKeys( + b64ToBytes(vectors.private_keys_concat_b64), + TEST_SIGNING_KEY_VERSION + ); + chat.setIdentity(TEST_USER_ID, TEST_SIGNING_KEY_VERSION); + return chat; +} + +/** + * Wrap fixture wasm Chat as a ChatWithJuicebox stand-in for mocked createChat. + * unlock/setup are no-ops — keys are already imported. + */ +function asJuiceboxStub(engine: WasmChat): ChatWithJuicebox { + return new Proxy(engine as object, { + get(target, prop, receiver) { + if (prop === "unlock" || prop === "setup") { + return async () => engine.getPublicKeys(); + } + if ( + prop === "delete" || + prop === "changePin" || + prop === "updateConfig" || + prop === "free" + ) { + return async () => undefined; + } + return Reflect.get(target, prop, receiver); + }, + }) as ChatWithJuicebox; +} + +// ── Create adapter with real crypto (Juicebox path, mocked createChat) ─ + +/** + * Stubbed XDK client injected into test adapters. Methods are plain vitest + * mocks and tests freely reassign or add entries, so the groups are typed as + * permissive records rather than mirroring the real client surface. + */ +export interface MockXdkClient { + chat: Record>; + users: Record>; +} + +/** + * Create and initialize an XchatAdapter with real wasm crypto. + * + * Wires a Juicebox-shaped stub (fixture keys already imported) and an XDK + * client without calling createChat — unit tests stay offline and avoid + * spying on ESM exports. + */ +export async function createInitializedTestAdapter( + configOverrides: Partial = {} +): Promise<{ + adapter: XchatAdapter; + mockChat: ReturnType; + /** Access the internal XDK client for mocking API calls */ + getXdkClient: () => MockXdkClient; + restore: () => void; +}> { + const mockChat = createMockChatInstance(); + const engine = await createTestCryptoEngine(); + + const adapter = new XchatAdapter({ + accessToken: "test-token", + userId: TEST_USER_ID, + pin: TEST_PIN, + userName: "test-bot", + logger: mockLogger, + ...configOverrides, + }); + + const internals = adapter as unknown as { + chat: ChatInstance | null; + xdkClient: unknown; + cryptoEngine: ChatWithJuicebox | null; + _cryptoStatus: string; + signingKeyVersion: string | null; + }; + + internals.chat = mockChat as unknown as ChatInstance; + const xdkClient: MockXdkClient = { + users: { + getPublicKey: vi.fn().mockResolvedValue({ data: [] }), + }, + chat: { + addConversationKeys: vi.fn(), + deleteMessages: vi.fn(), + getConversationEvents: vi.fn(), + markConversationRead: vi.fn().mockResolvedValue({ data: {} }), + sendMessage: vi.fn(), + sendTypingIndicator: vi.fn(), + mediaDownload: vi.fn(), + mediaUploadInitialize: vi.fn(), + mediaUploadAppend: vi.fn(), + mediaUploadFinalize: vi.fn(), + }, + }; + internals.xdkClient = xdkClient; + internals.cryptoEngine = asJuiceboxStub(engine); + internals.signingKeyVersion = TEST_SIGNING_KEY_VERSION; + internals._cryptoStatus = "ready"; + + return { + adapter, + mockChat, + getXdkClient: () => xdkClient, + restore: () => undefined, + }; +} diff --git a/packages/adapter-xchat/src/types.ts b/packages/adapter-xchat/src/types.ts new file mode 100644 index 00000000..3850fd37 --- /dev/null +++ b/packages/adapter-xchat/src/types.ts @@ -0,0 +1,262 @@ +/** + * Type definitions for the XChat adapter. + * + * XChat is X's encrypted messaging system. Messages are encrypted + * client-side with chat-xdk (WASM) and sent/received via the X API (/2/chat/*). + * + * @see https://docs.x.com/x-api/direct-messages/introduction + */ + +import type { Logger } from "chat"; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * XChat adapter configuration. + * + * Only `accessToken` and `userId` are required. The adapter fetches + * `signingKeyVersion` and Juicebox config automatically during `initialize()` + * by calling `users.getPublicKey(userId)`, then calls chat-xdk `createChat()`. + * + * Pass `pin` to auto-unlock after initialize, or call `adapter.unlock(pin)` later. + */ +export interface XchatAdapterConfig { + /** + * OAuth2 access token for X API calls. When omitted, resolved from the + * `XCHAT_BOT_TOKEN` or `X_ACCESS_TOKEN` env var by `createXchatAdapter`. + */ + accessToken?: string; + /** Base URL for X API requests. Defaults to https://api.x.com. */ + apiBaseUrl?: string; + /** Custom HTTP headers to send with every X API request */ + apiHeaders?: Record; + /** OAuth2 access token (alias for `accessToken`). */ + botToken?: string; + /** + * App consumer secret (API secret key) for webhook signature verification. + * When set, incoming POST webhooks are verified via HMAC-SHA256. + */ + consumerSecret?: string; + /** + * Minimum age (ms) a freshly posted message must reach before its first + * edit is sent, giving receiving clients time to store the original an + * edit targets. Defaults to 5000; 0 disables the wait. + */ + editSafetyDelayMs?: number; + /** + * Logger instance for error reporting. When omitted, `createXchatAdapter` + * falls back to a console logger. + */ + logger?: Logger; + /** + * Juicebox PIN. When set, initialize() calls unlock(pin) automatically + * after createChat(). + */ + pin?: string; + /** + * When true (default), a read receipt is sent for each inbound message + * before it is handed to handlers. Set to false to disable. + */ + sendReadReceipts?: boolean; + /** + * Override for the signing key version. When omitted, the adapter + * fetches it automatically from GET /2/users/{id}/public_keys during + * initialize(). Only set this if you know what you're doing. + */ + signingKeyVersion?: string; + /** + * Bot @handle for mention detection. When omitted, initialize() loads it + * from GET /2/users/me. + */ + userName?: string; + /** + * Whether incoming messages must have a verifiable signature. + * + * Defaults to `true` (chat-xdk's secure default): a message is only + * delivered when its signature verifies against a binding-checked signing + * key for the sender. Messages that can't be verified are dropped. + * + * Set to `false` to opt out of signature verification — messages decrypt + * even when no valid signing key is available. Use only when the binding + * verification can't be satisfied (e.g. participants registered with an + * incompatible key format) and you accept the reduced security. + */ + verifySignatures?: boolean; + /** + * Message posted when the bot is added to a group + * (`chat.conversation_join`). Set to `false` to disable. When omitted, a + * default that explains @mention-to-reply is used. + */ + welcomeMessage?: string | false; +} + +/** + * Encryption readiness states for the adapter. + * + * - `uninitialized`: initialize() not yet called + * - `initializing`: initialize() in progress (fetching keys, loading WASM) + * - `locked`: WASM loaded, Juicebox config fetched, waiting for unlock(pin) + * - `ready`: Keys loaded, adapter can encrypt/decrypt + * - `error`: Initialization or unlock failed + */ +export type XchatCryptoStatus = + | "uninitialized" + | "initializing" + | "locked" + | "ready" + | "error"; + +// ============================================================================= +// Thread ID +// ============================================================================= + +/** + * Decoded thread ID for XChat. + * + * XChat conversations can be 1:1 (userId1-userId2) or groups (gXXXXX). + * + * Format: xchat:{conversationId} + */ +export interface XchatThreadId { + /** The raw conversation ID (e.g. "12345-67890" or "gABCDE") */ + conversationId: string; +} + +// ============================================================================= +// XAA Event Payloads (X Activity API) +// ============================================================================= + +/** + * XAA chat event — the JSON payload delivered via webhook or activity stream. + * + * This is the same shape whether delivered via: + * - X Activity API webhook POST + * - X Activity API persistent HTTP stream + */ +export interface XchatEvent { + /** The conversation this event belongs to (e.g. "12345:67890" or "gABCDE") */ + conversationId: string; + /** Base64 key change event for extracting/rotating conversation keys */ + conversationKeyChangeEvent?: string; + /** Version of the conversation encryption key used */ + conversationKeyVersion?: string; + /** Conversation token for send continuity */ + conversationToken?: string; + /** Creation time as ISO 8601 (API conversation events) */ + createdAt?: string; + /** Timestamp in milliseconds (as string) */ + createdAtMsec?: string; + /** The base64-encoded encrypted event (the primary payload, decrypted via chat-xdk) */ + encodedEvent: string; + /** Base64 ECIES-encrypted conversation key for the bot user */ + encryptedConversationKey?: string; + /** Unique event ID (for dedup) */ + id: string; + /** Whether the message is from a trusted sender */ + isTrusted?: boolean; + /** Signature envelope */ + messageEventSignature?: { + publicKeyVersion?: string; + signature?: string; + signatureVersion?: string; + signingPublicKey?: string; + }; + /** Previous event ID in the conversation */ + previousId?: string; + /** Sender's numeric X user ID */ + senderId: string; + /** Backend sequence id (used for read receipts when present) */ + sequenceId?: string; +} + +/** + * XAA chat event types. + */ +export type XchatEventType = + | "chat.received" + | "chat.sent" + | "chat.conversation_join"; + +// ============================================================================= +// Raw Message Type +// ============================================================================= + +/** + * Platform-specific raw message type for XChat. + * + * Contains both the encrypted event envelope and the decrypted content. + */ +export interface XchatRawMessage { + /** The decrypted event from chat-xdk (null if decryption failed) */ + decrypted: XchatDecryptedEvent | null; + /** The raw XAA event or API response event */ + event: XchatEvent; +} + +/** + * A decrypted event as returned by chat-xdk. Declared as a plain local + * interface (instead of importing chat-xdk's types) so consumers don't need + * chat-xdk as a type dependency. + */ +export interface XchatDecryptedEvent { + attachments?: XchatAttachmentEntry[]; + content?: { + text?: string; + contentType?: string; + emoji?: string; + targetMessageId?: string; + entities?: unknown[]; + attachments?: XchatAttachmentEntry[]; + replyingToPreview?: unknown; + [key: string]: unknown; + }; + conversationId?: string; + createdAtMsec?: number; + id?: string; + keyVersion?: string; + mediaHashes?: Array<{ source?: string; mediaHashKey?: string }>; + participantKeys?: Array<{ + userId: string; + encryptedKey: string; + publicKeyVersion: string; + }>; + replyPreviewValidation?: "valid" | "invalid"; + senderId?: string; + sequenceId?: string; + ttlMsec?: number; + type: string; + verified?: boolean; + [key: string]: unknown; +} + +/** + * An attachment entry on a decrypted event. Tolerant of both the flattened + * camelCase shape produced by the chat-xdk JS binding and the nested + * snake_case `{ media: {...} }` shape seen on some wire payloads. + */ +export interface XchatAttachmentEntry { + attachmentType?: string; + dimensions?: { width?: number; height?: number }; + durationMillis?: number; + filename?: string; + filesizeBytes?: number; + media?: { + media_hash_key?: string; + mediaHashKey?: string; + dimensions?: { width?: number; height?: number }; + filesize_bytes?: number; + filesizeBytes?: number; + filename?: string; + media_type?: string; + mediaType?: string; + type?: string; + duration_millis?: number; + durationMillis?: number; + [key: string]: unknown; + }; + mediaHashKey?: string; + mediaType?: string; + [key: string]: unknown; +} diff --git a/packages/adapter-xchat/tests/fixtures/sdk_vectors.json b/packages/adapter-xchat/tests/fixtures/sdk_vectors.json new file mode 100644 index 00000000..2fb0827c --- /dev/null +++ b/packages/adapter-xchat/tests/fixtures/sdk_vectors.json @@ -0,0 +1,26 @@ +{ + "conversation_key_b64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM=", + "event_conversation_id": "1111:2222", + "event_conversation_key_version": "1001", + "event_failure_b64": "CwABAAAAATELAAIAAAANZmFpbHVyZS1tc2ctMQsAAwAAAAQxMTExCwAEAAAACTExMTE6MjIyMgsABgAAAA0xNzAwMDAwMDAwMDAwDAAHDAAFCAABAAAADAgAAgAAAAMAAAA=", + "event_garbage_b64": "!!!not-an-event!!!", + "event_key_change_b64": "CwABAAAAATELAAIAAAAIa2MtbXNnLTELAAMAAAAEMTExMQsABAAAAAkxMTExOjIyMjILAAYAAAANMTcwMDAwMDAwMDAwMAwABwwAAwsAAQAAAAQxMDAxDwACDAAAAAELAAEAAAAEMTExMQsAAgAAAJhCR0hGdmgzaUw3VitCbGZJRnpFM0E2VDBoRjNCbVM1RHhJaWp5R3Z0NEVVbVdKK2FFdWQweGZXSHNXcFJyOVBwU3hZSS9wNHNScGlkS3pkbXFXUmpZOFlVK3pQMFlIeFZidldMNWQ4MERtbEN2TmlBUFV1Ryszbldsdmx2Z3k2VFN5UnlaakZHb3JxUzArbitGVTd6cEU0PQsAAwAAAAExAAAADAAJCwABAAAAVmVpSkx2V1V0cDY1bkxVdWVEaUJDc01FSjJmQWY1c0d4T25aQ050ZHVEaUQxWWZ1UlQzSzhTTjFBcC9yN05YamZ6cEVWaXJCaTc5Q1N2WnE5a3dKaW1nCwACAAAAATELAAMAAAABNwAA", + "event_message_b64": "CwABAAAAATELAAIAAAAkMmMyYTFkMzItOTg4Yi00YjBlLTllNjMtYzNiYWFiMzAwM2ZkCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAABNyju49fSvP23md4h8MxYDM7/yP5czAJioJV+XI7e3bBM4gzkCp4+5iBBX4Jyee/0tULcB8Tkkl6qNbcRN9lWROcUxPyPGVVRp7WSqgVoLAGUAAAAEMTAwMQIAZgEAAAwACQsAAQAAAFZ4Rkk3NnA2VzZLY09jN0JGL0NLSjg4Sk5ZYnRvSitaS1VqdFFsTTNzYTRFdUdJZ1F2eHlLSG1tUkxqa0tlRUdMZkNEbU1EZDZTdlFMSTdiZVg0SFhZQQsAAgAAAAExCwADAAAAATcAAA==", + "event_message_text": "fixture event message", + "event_recipient_key_version": "1", + "event_reply_forged_b64": "CwABAAAAATELAAIAAAAkZGU3YTJiNzgtMTI4Ny00ZGUxLTliYzktNDQ3Mjc4YmYxNDUyCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAAHr2HANJYJJjNJD9rWtkjW68feM+I3vu++fxA1gEMBK2UjdNkd+cTKJ7AmPxxGOPq44F0ssa6ckES6aOLxF4WdMk1oY/hq27JPgyn88b9GVaevklTomM5Wb2WN8vS0CywxWXkY/iy9poJ+GWTGK34KfPen4osu57OO1lde151aOnNcDMj8WUVvR8aQujhYffmRiKzemwv94jdqH8v2oBOLgPEa1Kpt4qCASmDWZKc6nG8raBQxt8vRgl5mabT6tjEqWwjxCb+Jw82MOAR7cA6hM5Xf432cR1oU7DtYj89sOxbGaRVbHhzS1RgS3JrcKHCAvcn/0DHE06WIdL5ZMOPd3f3NMdzix7cZBidVf3Y98ceTzRAkPv+Py5QxbP7ljD0UhShOWfO6sWg69pwFYyl2iXwN87Ymzt+3beiECXFtZzArS3QiOp6ZzFXI4nGfkRP/xvnVQqBvjmbJMgyoPq8d+NMueC50+J1MKaaUZdbM7kWl9ey618OCodX8rdvzREDzWaNbT5r2ZzT+QUC7/UWhJrWR3UJ0cFF7n/Q2ZhF6QjApc1b4zNdt2QkAjXjj0VjTaZm33zcD4nItZHGu/GFesGbPv4QjWGNGMyHA6buZs4uLYePKoIL+junKoRK6rVycn8d6IrxOmzAbQQDYLAGUAAAAEMTAwMQIAZgEAAAwACQsAAQAAAFYrWHRQUVlIOUttZWNvWnN4Vk9ORlplcVRFb2Y3aEwwUDQ0TkxhYUlHN29ORlBKUmN6V2FkT2FWSnZyaFJyYWRZeE5NS0tCUjBVc0sxb0xOWVpJT2M1ZwsAAgAAAAExCwADAAAAATcAAA==", + "event_reply_forged_preview_text": "forged preview text", + "event_reply_text": "fixture reply message", + "event_reply_valid_b64": "CwABAAAAATELAAIAAAAkYzYzYjRmNTgtODU2Yi00YjQ5LTlmNGQtYTVjM2Y3MjM3ZjVmCwADAAAABDExMTELAAQAAAAJMTExMToyMjIyCwAGAAAADTE3MDAwMDAwMDAwMDAMAAcMAAELAGQAAAHtxf5QzAnBtH+rWXANpWMB3o5rf8xexfQC0/3pc/x/AaiqUpquGT+Tl1nDewt2x8BnIHra9V3//pMw1DNZp6X+d0bpJWWwACioojUy7xBmWsHquPCtOyUY40qKBNVtRPdL4LIOQPB/3TFNwqIE1F0WCs6M95T+U60d4/dp0X+OS1drQYQvzeQvOZ18YESWM0NE6L/yx+ugxttHcdFatZkPqAyilu2HYuv8OL2LvV4qWVG6DDg2Ixq/szkbl4Jfu61k613E3pj88avVyyDnCbavOT9T9Y46pznk3heuUJX9nOi1k2Kn5cx2l4SGTMglgILB70sc3qPATyHzflnM9ogqf9y70/N8EUT4FabppDeF8ghpqZs62weEAAjVq70+pFsj8imyYe91jIQiDZtZ8baPMa/O3v7U6soe5S7jbMHUzeU1WqR+Ad2QUT7X9DhSV5FrOaEEq21pDtCM7Ffh0I7VbYZRnmEumHBiLSgvWjqLKomxRnOXanSSSy0YgZbB2kPyrjC7aAnkbgiAZF5UzjO2T2iT+H2PXb821+Nxwf6kpbnZd6eCUSAPt3/s45AGGNol/NaXi446B31Ld6J8BTm5goeMMzPSzTUmEHVWmxF2R1SCYk5ulTqk5B0yJpxoLfKUiHywGfPb6KxNP9LO9wsAZQAAAAQxMDAxAgBmAQAADAAJCwABAAAAVkhGNGlkNDVrWXI1T1R6MUU3b3lxd1JCdnhsUGIyM2pGd3FDbElYS0ZZS0xjdHRhNUlFd0xvdlNOR2ZLdzdVTXJTRDRjWkFOZmJoRzdyQ3lJWk9CbklnCwACAAAAATELAAMAAAABNwAA", + "event_sender_id": "1111", + "event_signing_key_version": "1", + "identity_private_b64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE=", + "identity_public_b64": "BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+eFivOM1drMV7Oy7ZAaDe/UfU=", + "identity_public_key_signature_b64": "lwqF3bFJN47NLoYzSyTTCaA+eGe3g2rbDzWc120eJYV3ClHqD1NI79WKL60ciN0fBjhmuAnIq/Beq/GhPUu52A==", + "message_utf8": "chat-xdk test vector message", + "plaintext_b64": "SGVsbG8sIFhDSEFUIQ==", + "private_keys_concat_b64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAg==", + "signature_b64": "y2eG2ncBrqNtyxmdkCDuMhbNvZEKrQ1d7kyLHbNvHRLtdgZgMLiixNKFdKfLzbcwMLAgB/uDAjFABULfN3NLYg==", + "signing_private_b64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI=", + "signing_public_b64": "BHzyexiNA09+ilI4AwS1GsPAiWnid/IbNaYLSPxHZpl4B3dVENuO0EApPZrGn3Qw27p9reY86YIpngS3nSJ4c9E=" +} diff --git a/packages/adapter-xchat/tsconfig.json b/packages/adapter-xchat/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-xchat/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-xchat/tsup.config.ts b/packages/adapter-xchat/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-xchat/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-xchat/vitest.config.ts b/packages/adapter-xchat/vitest.config.ts new file mode 100644 index 00000000..5b01228b --- /dev/null +++ b/packages/adapter-xchat/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/packages/chat/src/adapters/index.ts b/packages/chat/src/adapters/index.ts index 5e629e3b..8c28307a 100644 --- a/packages/chat/src/adapters/index.ts +++ b/packages/chat/src/adapters/index.ts @@ -1093,6 +1093,48 @@ export const ADAPTERS = { slug: "x", type: "platform", }, + xchat: { + description: + "Hold encrypted 1:1 and group conversations on XChat with all cryptography handled inside the adapter.", + env: { + optional: [ + secretEnv( + "X_CONSUMER_SECRET", + "App consumer secret for webhook CRC and signature verification. Incoming webhook POSTs are not signature-verified when omitted." + ), + env( + "X_BOT_USERNAME", + "Bot account handle used for mention detection. Resolved from /2/users/me when omitted." + ), + env( + "X_VERIFY_SIGNATURES", + "Set to false to accept messages without verifiable signatures. Defaults to true." + ), + env( + "X_SIGNING_KEY_VERSION", + "Signing key version override. Fetched from the X API when omitted." + ), + ], + required: [ + secretEnv( + "XCHAT_BOT_TOKEN", + "OAuth 2.0 user access token for the bot account.", + { aliases: ["X_ACCESS_TOKEN"] } + ), + secretEnv( + "XCHAT_PIN", + "Juicebox PIN used to unlock the bot's private keys at startup." + ), + ], + }, + factoryExport: "createXchatAdapter", + group: "official", + name: "XChat", + packageName: "@chat-adapter/xchat", + peerDeps: ["@xdevplatform/chat-xdk", "@xdevplatform/xdk", "juicebox-sdk"], + slug: "xchat", + type: "platform", + }, zernio: { description: "Unified social media DM adapter covering Instagram, Facebook, Telegram, WhatsApp, X/Twitter, Bluesky, and Reddit through a single integration.", diff --git a/packages/create-chat-sdk/src/catalog/scaffold-spec.ts b/packages/create-chat-sdk/src/catalog/scaffold-spec.ts index f1d91054..1a02188a 100644 --- a/packages/create-chat-sdk/src/catalog/scaffold-spec.ts +++ b/packages/create-chat-sdk/src/catalog/scaffold-spec.ts @@ -329,6 +329,9 @@ export const CLI_SCAFFOLD_SPEC = { x: { invocation: { kind: "zero-arg" }, }, + xchat: { + invocation: { kind: "zero-arg" }, + }, zernio: { invocation: { kind: "zero-arg" }, }, diff --git a/packages/integration-tests/src/docs-adapters.test.ts b/packages/integration-tests/src/docs-adapters.test.ts index 656850d0..513f15de 100644 --- a/packages/integration-tests/src/docs-adapters.test.ts +++ b/packages/integration-tests/src/docs-adapters.test.ts @@ -262,6 +262,7 @@ describe("Official platform adapter OG images", () => { "web", "whatsapp", "x", + "xchat", ].sort() ); }); diff --git a/packages/integration-tests/src/documentation-test-utils.ts b/packages/integration-tests/src/documentation-test-utils.ts index 8d40884a..893714af 100644 --- a/packages/integration-tests/src/documentation-test-utils.ts +++ b/packages/integration-tests/src/documentation-test-utils.ts @@ -118,6 +118,7 @@ export const VALID_PACKAGE_README_IMPORTS = [ "@chat-adapter/twilio", "@chat-adapter/messenger", "@chat-adapter/x", + "@chat-adapter/xchat", "@chat-adapter/web", "@chat-adapter/web/react", "@chat-adapter/state-redis", @@ -172,6 +173,7 @@ export const VALID_DOC_PACKAGES = [ "@chat-adapter/twilio/webhook", "@chat-adapter/messenger", "@chat-adapter/x", + "@chat-adapter/xchat", "@chat-adapter/web", "@chat-adapter/web/react", "@chat-adapter/state-redis", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6163f2a..9501b54f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -720,6 +720,37 @@ importers: specifier: ^4.0.18 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@7.3.6(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/adapter-xchat: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + '@xdevplatform/chat-xdk': + specifier: ^0.4.3 + version: 0.4.3(juicebox-sdk@0.3.6) + '@xdevplatform/xdk': + specifier: ^0.6.6 + version: 0.6.6(node-fetch@3.3.2) + chat: + specifier: workspace:* + version: link:../chat + juicebox-sdk: + specifier: ^0.3.6 + version: 0.3.6 + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.9.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(@swc/core@1.15.3)(jiti@2.7.0)(postcss@8.5.16)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(vite@7.3.6(@types/node@25.9.2)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/chat: dependencies: '@workflow/serde': @@ -6388,6 +6419,24 @@ packages: '@workflow/world@5.0.0-beta.21': resolution: {integrity: sha512-XAOtSr7CGL/81EqEvUuDSWzil12sRJwQfU7ymVZlui33BKK8vbtgqzuY5vI6/OWt6NiAqPMeJVeahD9oMkii/A==} + '@xdevplatform/chat-xdk@0.4.3': + resolution: {integrity: sha512-OoR2RRJlCfrOp/iOrLotcQwgxVuahc9lQIFLx5Odgq3EwdNik7KvNeDl5sE05C2k/5FAwM/iYxOSMJEsEelasQ==} + engines: {node: '>=18'} + peerDependencies: + juicebox-sdk: ^0.3.0 + peerDependenciesMeta: + juicebox-sdk: + optional: true + + '@xdevplatform/xdk@0.6.6': + resolution: {integrity: sha512-ced4iHSz08eQe1L0N3UWVvJyfGnmGU0CRjecoXpr27dVIyQvc637Pxm3cdP4G85PwhHn5IGVJ1DJF2YD9zWfAg==} + engines: {node: '>=16.14'} + peerDependencies: + node-fetch: ^3.3.0 + peerDependenciesMeta: + node-fetch: + optional: true + '@xhmikosr/archive-type@8.1.0': resolution: {integrity: sha512-EXOjEbnZFE5c/nFMf4FOrEURVanzHpnkPYmnmr78u02/8hAhE0FMq8p9TK1IM0/bFr5VcyBUY0gfLm8f7dKy+Q==} engines: {node: '>=20'} @@ -8605,6 +8654,9 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + juicebox-sdk@0.3.6: + resolution: {integrity: sha512-j4B9ExvbY5xJRNc5MrddVJOpglpdGzoWbHSqeu58GvUkg51nl/5QMDU+s00ZHJGuXuJzjHZ6b2IK8CogEXg9Tg==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -17637,6 +17689,14 @@ snapshots: ulid: 3.0.2 zod: 4.3.6 + '@xdevplatform/chat-xdk@0.4.3(juicebox-sdk@0.3.6)': + optionalDependencies: + juicebox-sdk: 0.3.6 + + '@xdevplatform/xdk@0.6.6(node-fetch@3.3.2)': + optionalDependencies: + node-fetch: 3.3.2 + '@xhmikosr/archive-type@8.1.0': dependencies: file-type: 21.3.4 @@ -20189,6 +20249,8 @@ snapshots: ms: 2.1.3 semver: 7.8.5 + juicebox-sdk@0.3.6: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 diff --git a/turbo.json b/turbo.json index 76208a03..9ef0f805 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,12 @@ "X_USER_ACCESS_TOKEN", "X_USER_ID", "X_USERNAME", + "XCHAT_BOT_TOKEN", + "XCHAT_PIN", + "X_ACCESS_TOKEN", + "X_BOT_USERNAME", + "X_VERIFY_SIGNATURES", + "X_SIGNING_KEY_VERSION", "BOT_USERNAME", "REDIS_URL", "EDGE_CONFIG"