mirror of
https://github.com/vercel/chat.git
synced 2026-09-14 18:32:29 +08:00
4bca64f058
## summary
the X adapter previously rejected every attachment (`File uploads are
not supported by the X adapter yet`). this adds image upload support:
images passed as `files` or `attachments` are uploaded through X's v2
chunked media endpoints and attached to the resulting post or DM
- uploads via X's current path-based flow: `POST
/2/media/upload/initialize` (JSON) then `/{id}/append` (multipart) then
`/{id}/finalize`, reusing the adapter's managed OAuth token
- attaches `media_ids` on `POST /2/tweets` for posts, and `attachments`
for DMs
- supports png, jpeg, and webp, up to 4 per post, with or without text
- requires the `media.write` OAuth 2.0 scope on the user token
### before / after
- before: posting a message with `files`/`attachments` throws a
`ValidationError`
- after: images upload and attach, and a post can be media-only or media
plus text
<details>
<summary>usage</summary>
```typescript
await thread.post({
markdown: "France lead the title race",
files: [{ data: pngBuffer, filename: "odds.png", mimeType: "image/png" }],
});
```
</details>
## test plan
- added unit tests covering the initialize JSON body, the multipart
append path, finalize, `media_ids` on the tweet, DM `attachments`,
media-only posts, MIME inference from filename, the over-limit
rejection, and unsupported-type rejection
- verified live against the X API end to end: uploaded an image and
posted then deleted it through the adapter (the initial command-param
implementation 400'd against the live API, which is what surfaced the
path-based endpoints as required)
- `pnpm --filter @chat-adapter/x build`, tests, `pnpm exec biome check`,
and `pnpm konsistent` all pass
---------
Signed-off-by: dancer <josh@afterima.ge>
228 lines
8.4 KiB
Plaintext
228 lines
8.4 KiB
Plaintext
---
|
|
title: X
|
|
description: X (Twitter) adapter using the X API v2 and the X Activity API.
|
|
packageName: "@chat-adapter/x"
|
|
slug: x
|
|
type: platform
|
|
logo: x
|
|
tagline: Reply to public mentions and hold direct message conversations on X.
|
|
beta: true
|
|
features:
|
|
postMessage: yes
|
|
editMessage:
|
|
status: partial
|
|
label: Posts only
|
|
deleteMessage: yes
|
|
fileUploads: yes
|
|
streaming:
|
|
status: partial
|
|
label: Buffered
|
|
scheduledMessages: no
|
|
cardFormat:
|
|
status: partial
|
|
label: Plain text
|
|
buttons: no
|
|
linkButtons:
|
|
status: partial
|
|
label: Rendered as text
|
|
selectMenus: no
|
|
tables:
|
|
status: partial
|
|
label: ASCII
|
|
fields:
|
|
status: partial
|
|
label: Plain text
|
|
imagesInCards: no
|
|
modals: no
|
|
slashCommands: no
|
|
mentions: yes
|
|
addReactions:
|
|
status: partial
|
|
label: Likes only
|
|
removeReactions:
|
|
status: partial
|
|
label: Likes only
|
|
typingIndicator: no
|
|
directMessages: yes
|
|
ephemeralMessages: no
|
|
customApiEndpoint: yes
|
|
fetchMessages:
|
|
status: partial
|
|
label: DMs via API, posts cached
|
|
fetchSingleMessage:
|
|
status: partial
|
|
label: Posts via API, DMs cached
|
|
fetchThreadInfo: yes
|
|
fetchChannelMessages: no
|
|
listThreads: no
|
|
fetchChannelInfo: no
|
|
postChannelMessage: yes
|
|
---
|
|
|
|
## Install
|
|
|
|
<PackageInstall package="@chat-adapter/x" />
|
|
|
|
## Quick start
|
|
|
|
<Callout type="info">
|
|
The adapter auto-detects `X_CONSUMER_SECRET`, `X_USER_ACCESS_TOKEN`, `X_USER_ID`, and `X_USERNAME` from the environment.
|
|
</Callout>
|
|
|
|
```typescript title="lib/bot.ts" lineNumbers
|
|
import { Chat } from "chat";
|
|
import { createXAdapter } from "@chat-adapter/x";
|
|
|
|
const bot = new Chat({
|
|
userName: "mybot",
|
|
adapters: {
|
|
x: createXAdapter(),
|
|
},
|
|
});
|
|
|
|
bot.onNewMention(async (thread, message) => {
|
|
await thread.post(`Hi @${message.author.userName}!`);
|
|
});
|
|
|
|
bot.onDirectMessage(async (thread, message) => {
|
|
await thread.post("Hello from X!");
|
|
});
|
|
```
|
|
|
|
```typescript title="app/api/webhooks/x/route.ts" lineNumbers
|
|
import { bot } from "@/lib/bot";
|
|
|
|
export async function GET(request: Request) {
|
|
return bot.webhooks.x(request);
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
return bot.webhooks.x(request);
|
|
}
|
|
```
|
|
|
|
## Configuration
|
|
|
|
<TypeTable
|
|
type={{
|
|
consumerSecret: {
|
|
type: "string",
|
|
description:
|
|
"App API key secret used for webhook CRC responses and `x-twitter-webhooks-signature` verification. Auto-detected from `X_CONSUMER_SECRET`.",
|
|
},
|
|
userAccessToken: {
|
|
type: "string | () => Promise<string>",
|
|
description:
|
|
"OAuth 2.0 user-context access token, or an async provider for custom refresh flows. Auto-detected from `X_USER_ACCESS_TOKEN`. Optional when managed refresh is configured.",
|
|
},
|
|
clientId: {
|
|
type: "string",
|
|
description:
|
|
"OAuth 2.0 client ID, enables managed token refresh together with `refreshToken`. Auto-detected from `X_CLIENT_ID`.",
|
|
},
|
|
refreshToken: {
|
|
type: "string",
|
|
description:
|
|
"OAuth 2.0 refresh token (requires the `offline.access` scope). The adapter refreshes access tokens before expiry and persists the rotated refresh token in the state adapter. Auto-detected from `X_REFRESH_TOKEN`.",
|
|
},
|
|
clientSecret: {
|
|
type: "string",
|
|
description:
|
|
"OAuth 2.0 client secret for confidential clients. Auto-detected from `X_CLIENT_SECRET`.",
|
|
},
|
|
encryptionKey: {
|
|
type: "string",
|
|
description:
|
|
"Base64 32-byte AES-256-GCM key used to encrypt persisted OAuth tokens. Auto-detected from `X_ENCRYPTION_KEY`.",
|
|
},
|
|
userId: {
|
|
type: "string",
|
|
description:
|
|
"Bot account user ID, used for DM threading and self-detection. Auto-detected from `X_USER_ID`, otherwise fetched from `GET /2/users/me`. The adapter requires a resolvable bot id and fails initialization if neither is available.",
|
|
},
|
|
userName: {
|
|
type: "string",
|
|
description:
|
|
"Bot account handle used for mention detection. Auto-detected from `X_USERNAME`, otherwise fetched from `GET /2/users/me`.",
|
|
},
|
|
apiBaseUrl: {
|
|
type: "string",
|
|
default: '"https://api.x.com"',
|
|
description: "Override the X API base URL.",
|
|
},
|
|
}}
|
|
/>
|
|
|
|
## Authentication
|
|
|
|
### 1. Create an X app
|
|
|
|
1. Go to the [X developer portal](https://developer.x.com) and create a Project and App.
|
|
2. Under **Keys and tokens**, copy the **API Key Secret** to `X_CONSUMER_SECRET`.
|
|
3. Enable **OAuth 2.0** user authentication with the scopes `tweet.read`, `tweet.write`, `users.read`, `dm.read`, `dm.write`, `like.write`, `media.write`, and `offline.access`.
|
|
4. Complete the OAuth 2.0 flow for the bot account. Either store the access token as `X_USER_ACCESS_TOKEN`, or store `X_CLIENT_ID` plus `X_REFRESH_TOKEN` to let the adapter manage token refresh.
|
|
|
|
<Callout type="warn">
|
|
X OAuth 2.0 user tokens expire after about two hours. For long-running bots, configure managed refresh (`clientId` + `refreshToken`): the adapter refreshes tokens before expiry and persists the rotated refresh token in the state adapter, encrypted when `encryptionKey` is set.
|
|
</Callout>
|
|
|
|
### 2. Register a webhook and subscriptions
|
|
|
|
X delivers events through the [X Activity API](https://docs.x.com/x-api/activity/introduction). Set this up once, in the [X developer console](https://console.x.com), which handles the auth for you:
|
|
|
|
1. Register your webhook URL (`https://your-domain.com/api/webhooks/x`). It must be publicly reachable over HTTPS and cannot include a port. X sends a CRC challenge immediately and revalidates hourly; the adapter answers both automatically.
|
|
2. Create subscriptions for the events the adapter consumes: `post.mention.create`, `dm.received`, and `dm.sent`. These are private events, so the bot user must have authorized your app first.
|
|
|
|
<Callout type="info">
|
|
Subscription and webhook management is one-time setup, not something the adapter does at runtime. If you script it instead of using the console, note the Activity API endpoints are auth-picky and operation-specific (creating a private-event subscription needed OAuth 1.0a user context in testing, while list and delete used the app-only bearer token), and do not fully match the published spec. The console avoids all of this.
|
|
</Callout>
|
|
|
|
## Advanced
|
|
|
|
### Webhook flow
|
|
|
|
- **CRC challenge** (GET): X sends a `crc_token`, answered with `sha256=<base64 HMAC>` keyed by the consumer secret.
|
|
- **Event delivery** (POST): activity events signed via `x-twitter-webhooks-signature`, verified against the raw request body with timing-safe comparison.
|
|
|
|
### Streaming
|
|
|
|
X has no native streaming and public post edits are eligibility-gated, so the adapter buffers the entire stream and posts once on completion instead of using post+edit updates.
|
|
|
|
### Reply threading
|
|
|
|
Replies target the most recent mention received in the conversation, and consecutive replies chain under the bot's previous reply. Top-level posts go through `channel.post` on the `x:public` channel.
|
|
|
|
### Reactions
|
|
|
|
Likes are the only reaction X exposes. `addReaction` accepts `emoji.heart` or `"like"` and maps to `POST /2/users/:id/likes`; anything else throws a `ValidationError`.
|
|
|
|
### Media uploads
|
|
|
|
Attach images by passing `files` (or `attachments`) on the message. The adapter uploads each image (png, jpeg, webp) through X's chunked media endpoints (`initialize` then `append` then `finalize`) and attaches the returned `media_id`s. Posts and DMs accept up to four images, with or without text.
|
|
|
|
```typescript
|
|
await thread.post({
|
|
markdown: "France lead the title race",
|
|
files: [{ data: pngBuffer, filename: "odds.png", mimeType: "image/png" }],
|
|
});
|
|
```
|
|
|
|
Media upload requires the `media.write` scope on the OAuth 2.0 token, alongside `tweet.write`; without it uploads fail with a 403.
|
|
|
|
### Thread ID format
|
|
|
|
```
|
|
x:post:{conversationId} # public post threads (channel: x:public)
|
|
x:dm:{participantUserId} # direct message with a single user
|
|
```
|
|
|
|
Examples: `x:post:1943467279943467279`, `x:dm:783214`. X DM webhooks carry no conversation id, only participant ids, so DMs are threaded by the other participant's user id: `openDM("783214")` returns `x:dm:783214`, and both replies and inbound events for that conversation share the same thread. Sends route to the by-participant endpoint (`POST /2/dm_conversations/with/{id}/messages`).
|
|
|
|
### Automation policy
|
|
|
|
X enforces [automation rules](https://docs.x.com/developer-terms/policy): get explicit consent before automated replies or DMs, honor opt-outs immediately, disclose the bot identity in the account profile, and never send bulk or duplicate automated content.
|
|
|
|
## Feature support
|
|
|
|
<FeatureSupport />
|