Files
josh 4bca64f058 feat(x): support image uploads on posts and DMs (#700)
## 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>
2026-07-13 18:57:29 +10:00

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