Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/xchat-adapter.md
Original file line number Diff line number Diff line change
@@ -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/<version> 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
10 changes: 10 additions & 0 deletions apps/docs/adapters.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/adapters/official/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"twilio",
"messenger",
"x",
"xchat",
"web",
"---State---",
"memory",
Expand Down
Binary file added apps/docs/content/adapters/official/og/xchat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
263 changes: 263 additions & 0 deletions apps/docs/content/adapters/official/xchat.mdx
Original file line number Diff line number Diff line change
@@ -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

<PackageInstall package="@chat-adapter/xchat" />

## Quick start

<Callout type="info">
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.
</Callout>

```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

<TypeTable
type={{
botToken: {
type: "string",
description:
"OAuth 2.0 user access token for the bot account (also accepted as `accessToken`). Auto-detected from `XCHAT_BOT_TOKEN` or `X_ACCESS_TOKEN`.",
},
pin: {
type: "string",
description:
"Juicebox PIN; when set, the bot's private keys unlock automatically during `initialize()`. Auto-detected from `XCHAT_PIN`.",
},
consumerSecret: {
type: "string",
description:
"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: {
type: "number",
default: "5000",
description:
"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. `0` disables the wait.",
},
sendReadReceipts: {
type: "boolean",
default: "true",
description:
"Send a read receipt for each delivered inbound message before handlers run.",
},
userName: {
type: "string",
description:
"Bot @handle for mention detection. Auto-detected from `X_BOT_USERNAME`, otherwise resolved from `GET /2/users/me`.",
},
welcomeMessage: {
type: "string | false",
description:
"Message posted when the bot joins a group. `false` disables; omitted uses a default that explains @mention-to-reply.",
},
verifySignatures: {
type: "boolean",
default: "true",
description:
"Require verifiable signatures on incoming messages. `X_VERIFY_SIGNATURES=false` opts out.",
},
signingKeyVersion: {
type: "string",
description:
"Signing key version override. Normally fetched during `initialize()`. Auto-detected from `X_SIGNING_KEY_VERSION`.",
},
apiBaseUrl: {
type: "string",
default: '"https://api.x.com"',
description: "Base URL for media REST calls.",
},
}}
/>

## 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

<FeatureSupport />
Loading