From 118007c3750e27639cf82c6e45f5086ed02b7540 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Fri, 20 Mar 2026 14:01:30 +0530 Subject: [PATCH] feat(adapter-twitter): add new Twitter/X webhook adapter --- .changeset/add-twitter-adapter.md | 11 + apps/docs/content/docs/adapters.mdx | 72 +- packages/adapter-twitter/README.md | 94 ++ packages/adapter-twitter/package.json | 58 + packages/adapter-twitter/sample-messages.md | 140 +++ packages/adapter-twitter/src/index.test.ts | 416 +++++++ packages/adapter-twitter/src/index.ts | 1068 +++++++++++++++++ packages/adapter-twitter/src/markdown.test.ts | 65 + packages/adapter-twitter/src/markdown.ts | 65 + packages/adapter-twitter/src/types.ts | 251 ++++ packages/adapter-twitter/tsconfig.json | 10 + packages/adapter-twitter/tsup.config.ts | 9 + packages/adapter-twitter/vitest.config.ts | 8 + pnpm-lock.yaml | 22 + vitest.config.ts | 3 + 15 files changed, 2256 insertions(+), 36 deletions(-) create mode 100644 .changeset/add-twitter-adapter.md create mode 100644 packages/adapter-twitter/README.md create mode 100644 packages/adapter-twitter/package.json create mode 100644 packages/adapter-twitter/sample-messages.md create mode 100644 packages/adapter-twitter/src/index.test.ts create mode 100644 packages/adapter-twitter/src/index.ts create mode 100644 packages/adapter-twitter/src/markdown.test.ts create mode 100644 packages/adapter-twitter/src/markdown.ts create mode 100644 packages/adapter-twitter/src/types.ts create mode 100644 packages/adapter-twitter/tsconfig.json create mode 100644 packages/adapter-twitter/tsup.config.ts create mode 100644 packages/adapter-twitter/vitest.config.ts diff --git a/.changeset/add-twitter-adapter.md b/.changeset/add-twitter-adapter.md new file mode 100644 index 00000000..d0b3ca9e --- /dev/null +++ b/.changeset/add-twitter-adapter.md @@ -0,0 +1,11 @@ +--- +"@chat-adapter/twitter": minor +--- + +Initial release of the Twitter / X adapter. + +* Receive DMs natively via Account Activity API webhooks +* Post DMs via X API v2 +* CRC Challenge hash verification +* AST to plain-text and ASCII table rendering support + diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index e0ca5a39..98271d23 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -14,51 +14,51 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid ### Messaging -| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | -|---------|-------|-------|-------------|---------|---------|--------|--------|-----------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | -| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | [Twitter](/adapters/twitter) | +|---------|-------|-------|-------------|---------|---------|--------|--------|-----------|---------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | ❌ | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | ❌ | +| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Twitter | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|---------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | Plain text | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | ❌ | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | ⚠️ ASCII | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | ❌ | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Twitter | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|---------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Twitter | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|---------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | ⚠️ Cached | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | ⚠️ Cached | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | ❌ | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ❌ | ⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. diff --git a/packages/adapter-twitter/README.md b/packages/adapter-twitter/README.md new file mode 100644 index 00000000..dfa860da --- /dev/null +++ b/packages/adapter-twitter/README.md @@ -0,0 +1,94 @@ +# @chat-adapter/twitter + +[![npm version](https://img.shields.io/npm/v/@chat-adapter/twitter)](https://www.npmjs.com/package/@chat-adapter/twitter) +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/twitter)](https://www.npmjs.com/package/@chat-adapter/twitter) + +Twitter / X Webhooks adapter for [Chat SDK](https://chat-sdk.dev/docs). + +This adapter uses the **X Account Activity API** (Enterprise/Pro tier required) to receive Direct Messages in real-time and the **X API v2** to send responses. + +## Installation + +```bash +npm install chat @chat-adapter/twitter +``` + +## Usage + +```typescript +import { Chat } from "chat"; +import { createTwitterAdapter } from "@chat-adapter/twitter"; +import { createMemoryState } from "@chat-adapter/state-memory"; + +const bot = new Chat({ + userName: "my_twitter_bot", + adapters: { + twitter: createTwitterAdapter({ + consumerKey: process.env.TWITTER_CONSUMER_KEY!, + consumerSecret: process.env.TWITTER_CONSUMER_SECRET!, + accessToken: process.env.TWITTER_ACCESS_TOKEN!, + accessTokenSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET!, + bearerToken: process.env.TWITTER_BEARER_TOKEN!, + }), + }, + state: createMemoryState(), // Required for deduping +}); + +// Twitter DMs are treated as standard messages (not mentions) +bot.onNewMessage(async (thread, message) => { + await thread.post(`Echo: ${message.text}`); +}); +``` + +## Environment variables + +If you don't pass options into `createTwitterAdapter()`, it will automatically read from these environment variables: + +| Variable | Required | Description | +|----------|----------|-------------| +| `TWITTER_CONSUMER_KEY` | Yes | App API Key | +| `TWITTER_CONSUMER_SECRET` | Yes | App API Secret | +| `TWITTER_ACCESS_TOKEN` | Yes | Bot account access token | +| `TWITTER_ACCESS_TOKEN_SECRET` | Yes | Bot account access token secret | +| `TWITTER_BEARER_TOKEN` | Yes | App Bearer token (for v2 read endpoints) | +| `TWITTER_BOT_USERNAME` | No | Override the bot display name | +| `TWITTER_WEBHOOK_ENV` | No | Account Activity environment name (default: "production") | + +## Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `consumerKey` | `string` | `process.env.TWITTER_CONSUMER_KEY` | App API Key | +| `consumerSecret` | `string` | `process.env.TWITTER_CONSUMER_SECRET` | App API Secret for CRC hashing | +| `accessToken` | `string` | `process.env.TWITTER_ACCESS_TOKEN` | Bot account access token | +| `accessTokenSecret` | `string` | `process.env.TWITTER_ACCESS_TOKEN_SECRET` | Bot account access token secret | +| `bearerToken` | `string` | `process.env.TWITTER_BEARER_TOKEN` | App Bearer token | +| `userName` | `string` | `process.env.TWITTER_BOT_USERNAME` | Bot display name | +| `apiBaseUrl` | `string` | `"https://api.twitter.com"` | Override domain for testing | + +## Platform setup + +1. Create a project in the [X Developer Portal](https://developer.x.com). +2. Generate your **Consumer Key**, **Consumer Secret**, and **Bearer Token**. +3. Set up **OAuth 1.0a User Authentication** in your app settings with Read/Write/Direct Messages permissions. +4. Generate the **Access Token** and **Access Token Secret** for your bot account. +5. Apply for the **Account Activity API** (requires Pro or Enterprise access). +6. Start your server so the webhook endpoint is active. +7. Register your webhook URL and subscribe your bot account using the Account Activity API. + +## Features + +- **Direct Messages**: Receive and reply to 1-1 DMs +- **CRC Hashing**: Automatically responds to Twitter's Challenge-Response Checks +- **Media Attachments**: Extracts image and video URLs from incoming DMs +- **Plain Text Rendering**: Automatically converts markdown AST to plain text (with ASCII tables) since Twitter DMs don't support rich formatting + +### Limitations +- **No Message Editing**: The Twitter API does not support editing DMs. `editMessage` throws `NotImplementedError`. +- **Typing Indicators**: The X API doesn't support bot typing indicators. +- **Rate Limits**: The DM API is subject to X's strict rate limits. +- **Premium Tier Requirement**: Requires Account Activity API access, which is not available on free or basic tiers. + +## License + +MIT diff --git a/packages/adapter-twitter/package.json b/packages/adapter-twitter/package.json new file mode 100644 index 00000000..124048ed --- /dev/null +++ b/packages/adapter-twitter/package.json @@ -0,0 +1,58 @@ +{ + "name": "@chat-adapter/twitter", + "version": "0.1.0", + "description": "Twitter / X adapter for chat", + "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:*" + }, + "peerDependencies": { + "chat": "workspace:*" + }, + "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-twitter" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "twitter", + "x", + "bot", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-twitter/sample-messages.md b/packages/adapter-twitter/sample-messages.md new file mode 100644 index 00000000..50d390a3 --- /dev/null +++ b/packages/adapter-twitter/sample-messages.md @@ -0,0 +1,140 @@ +# Twitter / X Sample Messages + +## Direct Message Webhook Payload + +This is what a standard payload looks like when a user sends a Direct Message to the bot, as delivered by the Account Activity API. + +```json +{ + "for_user_id": "987654321", + "direct_message_events": [ + { + "type": "message_create", + "id": "1638290192839218391", + "created_timestamp": "1679354012000", + "message_create": { + "target": { + "recipient_id": "987654321" + }, + "sender_id": "123456789", + "message_data": { + "text": "Hello Twitter bot!", + "entities": { + "hashtags": [], + "symbols": [], + "user_mentions": [], + "urls": [] + } + } + } + } + ], + "users": { + "123456789": { + "id": "123456789", + "created_timestamp": "1422556069340", + "name": "Alex Developer", + "screen_name": "alexdev", + "protected": false, + "verified": true, + "followers_count": 1500, + "friends_count": 800, + "statuses_count": 5200, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/123/avatar.jpg" + }, + "987654321": { + "id": "987654321", + "created_timestamp": "1512340000000", + "name": "Cool Bot", + "screen_name": "cool_bot_123", + "protected": false, + "verified": false, + "followers_count": 50, + "friends_count": 2, + "statuses_count": 15, + "profile_image_url_https": "https://pbs.twimg.com/profile_images/456/bot.jpg" + } + } +} +``` + +## Challenge-Response Check (CRC) Request + +Twitter sporadically issues GET requests to verify webhook ownership. The URL will look like: + +```text +GET https://your-domain.com/api/webhooks/twitter?crc_token=1CDehg9... +``` + +The adapter computes the HMAC-SHA256 of `1CDehg9...` using the `TWITTER_CONSUMER_SECRET` and responds with: + +```json +{ + "response_token": "sha256=MzA3O..." +} +``` + +## Direct Message with Media Attachment + +When a user attaches an image or video to a DM. + +```json +{ + "for_user_id": "987654321", + "direct_message_events": [ + { + "type": "message_create", + "id": "1638290192839218392", + "created_timestamp": "1679354055000", + "message_create": { + "target": { + "recipient_id": "987654321" + }, + "sender_id": "123456789", + "message_data": { + "text": "Check out this screenshot https://t.co/abc123def", + "entities": { + "hashtags": [], + "symbols": [], + "user_mentions": [], + "urls": [ + { + "url": "https://t.co/abc123def", + "expanded_url": "...", + "display_url": "pic.twitter.com/xyz", + "indices": [24, 46] + } + ] + }, + "attachment": { + "type": "media", + "media": { + "id": 16382901500000000, + "id_str": "1638290150000000000", + "media_url": "http://pbs.twimg.com/media/FxsX_Y_WYAETZ...jpg", + "media_url_https": "https://pbs.twimg.com/media/FxsX_Y_WYAETZ...jpg", + "url": "https://t.co/abc123def", + "display_url": "pic.twitter.com/xyz", + "expanded_url": "https://twitter.com/messages/media/1638290150000000000", + "type": "photo", + "sizes": { + "medium": { "w": 1200, "h": 675, "resize": "fit" }, + "thumb": { "w": 150, "h": 150, "resize": "crop" }, + "small": { "w": 680, "h": 383, "resize": "fit" }, + "large": { "w": 1920, "h": 1080, "resize": "fit" } + } + } + } + } + } + } + ], + "users": { + "123456789": { + "id": "123456789", + "name": "Alex Developer", + "screen_name": "alexdev" + } + } +} +``` diff --git a/packages/adapter-twitter/src/index.test.ts b/packages/adapter-twitter/src/index.test.ts new file mode 100644 index 00000000..eaef5ea9 --- /dev/null +++ b/packages/adapter-twitter/src/index.test.ts @@ -0,0 +1,416 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { TwitterAdapter, createTwitterAdapter } from "./index"; +import type { + TwitterAccountActivityPayload, + TwitterDirectMessageEvent, +} from "./types"; + +// Mock environment variables for all tests +const mockConfig = { + consumerKey: "test-consumer-key", + consumerSecret: "test-consumer-secret", + accessToken: "test-access-token", + accessTokenSecret: "test-access-token-secret", + bearerToken: "test-bearer-token", + userName: "testbot", + apiBaseUrl: "https://mock-twitter-api.test", +}; + +function createTestAdapter( + overrides?: Partial +): TwitterAdapter { + return new TwitterAdapter({ ...mockConfig, ...overrides }); +} + +function createTestDMEvent( + overrides?: Partial<{ + id: string; + senderId: string; + recipientId: string; + text: string; + timestamp: string; + }> +): TwitterDirectMessageEvent { + return { + type: "message_create", + id: overrides?.id ?? "dm-event-123", + created_timestamp: overrides?.timestamp ?? String(Date.now()), + message_create: { + target: { + recipient_id: overrides?.recipientId ?? "bot-user-id", + }, + sender_id: overrides?.senderId ?? "user-456", + message_data: { + text: overrides?.text ?? "Hello bot!", + }, + }, + }; +} + +function createTestWebhookPayload( + overrides?: Partial<{ + forUserId: string; + events: TwitterDirectMessageEvent[]; + users: TwitterAccountActivityPayload["users"]; + }> +): TwitterAccountActivityPayload { + return { + for_user_id: overrides?.forUserId ?? "bot-user-id", + direct_message_events: overrides?.events ?? [createTestDMEvent()], + users: overrides?.users ?? { + "user-456": { + id: "user-456", + created_timestamp: "1422556069340", + name: "Test User", + screen_name: "testuser", + protected: false, + verified: false, + followers_count: 10, + friends_count: 20, + statuses_count: 100, + }, + "bot-user-id": { + id: "bot-user-id", + created_timestamp: "1422556069340", + name: "Test Bot", + screen_name: "testbot", + protected: false, + verified: false, + followers_count: 0, + friends_count: 0, + statuses_count: 0, + }, + }, + }; +} + +describe("TwitterAdapter", () => { + describe("constructor", () => { + it("should create adapter with valid config", () => { + const adapter = createTestAdapter(); + expect(adapter.name).toBe("twitter"); + expect(adapter.userName).toBe("testbot"); + }); + + it("should throw if consumer key is missing", () => { + expect( + () => + new TwitterAdapter({ + ...mockConfig, + consumerKey: undefined, + }) + ).toThrow("Consumer key is required"); + }); + + it("should throw if consumer secret is missing", () => { + expect( + () => + new TwitterAdapter({ + ...mockConfig, + consumerSecret: undefined, + }) + ).toThrow("Consumer secret is required"); + }); + + it("should throw if access token is missing", () => { + expect( + () => + new TwitterAdapter({ + ...mockConfig, + accessToken: undefined, + }) + ).toThrow("Access token is required"); + }); + + it("should throw if access token secret is missing", () => { + expect( + () => + new TwitterAdapter({ + ...mockConfig, + accessTokenSecret: undefined, + }) + ).toThrow("Access token secret is required"); + }); + + it("should throw if bearer token is missing", () => { + expect( + () => + new TwitterAdapter({ + ...mockConfig, + bearerToken: undefined, + }) + ).toThrow("Bearer token is required"); + }); + }); + + describe("thread ID encoding/decoding", () => { + it("should encode thread ID", () => { + const adapter = createTestAdapter(); + const threadId = adapter.encodeThreadId({ + conversationId: "123-456", + }); + expect(threadId).toBe("twitter:123-456"); + }); + + it("should decode thread ID", () => { + const adapter = createTestAdapter(); + const decoded = adapter.decodeThreadId("twitter:123-456"); + expect(decoded.conversationId).toBe("123-456"); + }); + + it("should throw on invalid thread ID format", () => { + const adapter = createTestAdapter(); + expect(() => adapter.decodeThreadId("slack:123")).toThrow( + "Invalid Twitter thread ID" + ); + }); + + it("should throw on thread ID with too many parts", () => { + const adapter = createTestAdapter(); + expect(() => adapter.decodeThreadId("twitter:123:456")).toThrow( + "Invalid Twitter thread ID" + ); + }); + + it("should roundtrip encode/decode", () => { + const adapter = createTestAdapter(); + const original = { conversationId: "abc-def" }; + const encoded = adapter.encodeThreadId(original); + const decoded = adapter.decodeThreadId(encoded); + expect(decoded).toEqual(original); + }); + }); + + describe("channelIdFromThreadId", () => { + it("should extract channel ID from thread ID", () => { + const adapter = createTestAdapter(); + const channelId = adapter.channelIdFromThreadId("twitter:123-456"); + expect(channelId).toBe("twitter:123-456"); + }); + }); + + describe("isDM", () => { + it("should always return true for Twitter adapter", () => { + const adapter = createTestAdapter(); + expect(adapter.isDM("twitter:123-456")).toBe(true); + }); + }); + + describe("handleWebhook - CRC Challenge", () => { + it("should respond to CRC challenge with correct hash", async () => { + const adapter = createTestAdapter(); + const crcToken = "test-crc-token"; + const request = new Request( + `https://example.com/webhook?crc_token=${crcToken}`, + { method: "GET" } + ); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.response_token).toBeDefined(); + expect(body.response_token).toMatch(/^sha256=/); + }); + + it("should return 400 if crc_token is missing", async () => { + const adapter = createTestAdapter(); + const request = new Request("https://example.com/webhook", { + method: "GET", + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); + }); + + describe("handleWebhook - POST events", () => { + it("should return 200 for valid DM webhook", async () => { + const adapter = createTestAdapter(); + + // Initialize with a mock chat instance + const mockChat = { + getLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + child: vi.fn().mockReturnThis(), + }), + getUserName: () => "testbot", + processMessage: vi.fn(), + getState: vi.fn(), + }; + + // Mock fetch for /2/users/me + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: "bot-user-id", + name: "Test Bot", + username: "testbot", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + try { + await adapter.initialize(mockChat as any); + + const payload = createTestWebhookPayload(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { "Content-Type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processMessage).toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("should return 400 for invalid JSON", 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 when chat is not initialized", async () => { + const adapter = createTestAdapter(); + const payload = createTestWebhookPayload(); + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify(payload), + headers: { "Content-Type": "application/json" }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + }); + }); + + describe("parseMessage", () => { + it("should parse a raw DM event into a Message", () => { + const adapter = createTestAdapter(); + const dmEvent = createTestDMEvent({ + text: "Hello from test", + senderId: "user-123", + recipientId: "user-456", + }); + + const message = adapter.parseMessage(dmEvent); + expect(message.text).toBe("Hello from test"); + expect(message.author.userId).toBe("user-123"); + expect(message.id).toBe("dm-event-123"); + }); + + it("should set isMention when bot username is in text", () => { + const adapter = createTestAdapter(); + const dmEvent = createTestDMEvent({ + text: "Hey @testbot can you help?", + senderId: "user-123", + recipientId: "user-456", + }); + + const message = adapter.parseMessage(dmEvent); + expect(message.isMention).toBe(true); + }); + + it("should not set isMention when bot is not mentioned", () => { + const adapter = createTestAdapter(); + const dmEvent = createTestDMEvent({ + text: "Hello world", + senderId: "user-123", + recipientId: "user-456", + }); + + const message = adapter.parseMessage(dmEvent); + expect(message.isMention).toBe(false); + }); + }); + + describe("fetchMessages", () => { + it("should return empty array for unknown thread", async () => { + const adapter = createTestAdapter(); + const result = await adapter.fetchMessages("twitter:unknown"); + expect(result.messages).toEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + + it("should return cached messages after parsing", async () => { + const adapter = createTestAdapter(); + const dmEvent = createTestDMEvent({ + senderId: "user-123", + recipientId: "user-456", + }); + + const message = adapter.parseMessage(dmEvent); + const result = await adapter.fetchMessages(message.threadId); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].id).toBe("dm-event-123"); + }); + }); + + describe("fetchThread", () => { + it("should return thread info", async () => { + const adapter = createTestAdapter(); + const info = await adapter.fetchThread("twitter:123-456"); + expect(info.id).toBe("twitter:123-456"); + expect(info.isDM).toBe(true); + expect(info.channelId).toBe("123-456"); + }); + }); + + describe("editMessage", () => { + it("should throw NotImplementedError", async () => { + const adapter = createTestAdapter(); + await expect( + adapter.editMessage("twitter:123", "msg1", "new text") + ).rejects.toThrow("Twitter DMs cannot be edited"); + }); + }); + + describe("renderFormatted", () => { + it("should render formatted content to string", () => { + const adapter = createTestAdapter(); + const ast = { + type: "root" as const, + children: [ + { + type: "paragraph" as const, + children: [{ type: "text" as const, value: "Hello" }], + }, + ], + }; + const result = adapter.renderFormatted(ast); + expect(result).toBe("Hello"); + }); + }); + + describe("createTwitterAdapter factory", () => { + it("should create adapter with config", () => { + const adapter = createTwitterAdapter(mockConfig); + expect(adapter).toBeInstanceOf(TwitterAdapter); + expect(adapter.name).toBe("twitter"); + }); + }); + + describe("persistMessageHistory", () => { + it("should be true", () => { + const adapter = createTestAdapter(); + expect(adapter.persistMessageHistory).toBe(true); + }); + }); +}); diff --git a/packages/adapter-twitter/src/index.ts b/packages/adapter-twitter/src/index.ts new file mode 100644 index 00000000..d79d4863 --- /dev/null +++ b/packages/adapter-twitter/src/index.ts @@ -0,0 +1,1068 @@ +import { + AdapterRateLimitError, + AuthenticationError, + cardToFallbackText, + extractCard, + NetworkError, + PermissionError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message, NotImplementedError } from "chat"; +import { TwitterFormatConverter } from "./markdown"; +import type { + TwitterAccountActivityPayload, + TwitterAdapterConfig, + TwitterApiV2Response, + TwitterDirectMessageEvent, + TwitterDMSendResponse, + TwitterRawMessage, + TwitterThreadId, + TwitterUser, + TwitterUserV2, +} from "./types"; + +const TWITTER_API_BASE = "https://api.twitter.com"; +const TWITTER_DM_MESSAGE_LIMIT = 10000; +const CRC_TOKEN_PARAM = "crc_token"; + +interface TwitterMessageAuthor { + fullName: string; + isBot: boolean | "unknown"; + isMe: boolean; + userId: string; + userName: string; +} + +/** + * Compute HMAC-SHA256 for CRC validation. + * Uses the Web Crypto API (available in Node 18+ and edge runtimes). + */ +async function computeHmacSha256( + key: string, + message: string +): Promise { + const encoder = new TextEncoder(); + const keyData = encoder.encode(key); + const messageData = encoder.encode(message); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData); + return btoa(String.fromCharCode(...new Uint8Array(signature))); +} + +/** + * Generate OAuth 1.0a Authorization header. + * + * This is a simplified OAuth 1.0a implementation for the X API. + * It generates the HMAC-SHA1 signature required by Twitter's API. + */ +async function generateOAuth1Header(params: { + method: string; + url: string; + consumerKey: string; + consumerSecret: string; + accessToken: string; + accessTokenSecret: string; + additionalParams?: Record; +}): Promise { + const { + method, + url, + consumerKey, + consumerSecret, + accessToken, + accessTokenSecret, + additionalParams, + } = params; + + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = crypto.randomUUID().replace(/-/g, ""); + + const oauthParams: Record = { + oauth_consumer_key: consumerKey, + oauth_nonce: nonce, + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: timestamp, + oauth_token: accessToken, + oauth_version: "1.0", + ...additionalParams, + }; + + // Parse URL to get any query parameters + const parsedUrl = new URL(url); + for (const [key, value] of parsedUrl.searchParams.entries()) { + oauthParams[key] = value; + } + + // Sort parameters + const sortedParams = Object.entries(oauthParams) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join("&"); + + // Base URL without query string + const baseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`; + + // Create signature base string + const signatureBase = `${method.toUpperCase()}&${encodeURIComponent(baseUrl)}&${encodeURIComponent(sortedParams)}`; + const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(accessTokenSecret)}`; + + // Compute HMAC-SHA1 + const encoder = new TextEncoder(); + const keyData = encoder.encode(signingKey); + const messageData = encoder.encode(signatureBase); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"] + ); + + const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData); + const signatureBase64 = btoa( + String.fromCharCode(...new Uint8Array(signature)) + ); + + // Build Authorization header + const authParams: Record = { + oauth_consumer_key: consumerKey, + oauth_nonce: nonce, + oauth_signature: signatureBase64, + oauth_signature_method: "HMAC-SHA1", + oauth_timestamp: timestamp, + oauth_token: accessToken, + oauth_version: "1.0", + }; + + const authHeader = Object.entries(authParams) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, value]) => + `${encodeURIComponent(key)}="${encodeURIComponent(value)}"` + ) + .join(", "); + + return `OAuth ${authHeader}`; +} + +export class TwitterAdapter + implements Adapter +{ + readonly name = "twitter"; + readonly persistMessageHistory = true; + + private readonly consumerKey: string; + private readonly consumerSecret: string; + private readonly accessToken: string; + private readonly accessTokenSecret: string; + private readonly bearerToken: string; + private readonly apiBaseUrl: string; + private readonly webhookEnvironment: string; + private readonly logger: Logger; + private readonly formatConverter = new TwitterFormatConverter(); + private readonly messageCache = new Map< + string, + Message[] + >(); + + private chat: ChatInstance | null = null; + private _botUserId?: string; + private _userName: string; + private readonly hasExplicitUserName: boolean; + + get botUserId(): string | undefined { + return this._botUserId; + } + + get userName(): string { + return this._userName; + } + + constructor(config: TwitterAdapterConfig = {}) { + const consumerKey = + config.consumerKey ?? process.env.TWITTER_CONSUMER_KEY; + const consumerSecret = + config.consumerSecret ?? process.env.TWITTER_CONSUMER_SECRET; + const accessToken = + config.accessToken ?? process.env.TWITTER_ACCESS_TOKEN; + const accessTokenSecret = + config.accessTokenSecret ?? process.env.TWITTER_ACCESS_TOKEN_SECRET; + const bearerToken = + config.bearerToken ?? process.env.TWITTER_BEARER_TOKEN; + + if (!consumerKey) { + throw new ValidationError( + "twitter", + "Consumer key is required. Set TWITTER_CONSUMER_KEY or provide it in config." + ); + } + if (!consumerSecret) { + throw new ValidationError( + "twitter", + "Consumer secret is required. Set TWITTER_CONSUMER_SECRET or provide it in config." + ); + } + if (!accessToken) { + throw new ValidationError( + "twitter", + "Access token is required. Set TWITTER_ACCESS_TOKEN or provide it in config." + ); + } + if (!accessTokenSecret) { + throw new ValidationError( + "twitter", + "Access token secret is required. Set TWITTER_ACCESS_TOKEN_SECRET or provide it in config." + ); + } + if (!bearerToken) { + throw new ValidationError( + "twitter", + "Bearer token is required. Set TWITTER_BEARER_TOKEN or provide it in config." + ); + } + + this.consumerKey = consumerKey; + this.consumerSecret = consumerSecret; + this.accessToken = accessToken; + this.accessTokenSecret = accessTokenSecret; + this.bearerToken = bearerToken; + this.apiBaseUrl = ( + config.apiBaseUrl ?? + process.env.TWITTER_API_BASE_URL ?? + TWITTER_API_BASE + ).replace(/\/+$/, ""); + this.webhookEnvironment = + config.webhookEnvironment ?? + process.env.TWITTER_WEBHOOK_ENV ?? + "production"; + this.logger = + config.logger ?? new ConsoleLogger("info").child("twitter"); + + const userName = + config.userName ?? process.env.TWITTER_BOT_USERNAME; + this._userName = userName ?? "bot"; + this.hasExplicitUserName = Boolean(userName); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + if (!this.hasExplicitUserName) { + const chatUserName = chat.getUserName?.(); + if (typeof chatUserName === "string" && chatUserName.trim()) { + this._userName = chatUserName; + } + } + + // Fetch bot's own user info via API v2 + try { + const me = await this.twitterFetchV2( + "/2/users/me", + "GET" + ); + if (me) { + this._botUserId = me.id; + if (!this.hasExplicitUserName && me.username) { + this._userName = me.username; + } + } + + this.logger.info("Twitter adapter initialized", { + botUserId: this._botUserId, + userName: this._userName, + }); + } catch (error) { + this.logger.warn("Failed to fetch Twitter bot identity", { + error: String(error), + }); + } + } + + /** + * Handle incoming webhook requests from the X Account Activity API. + * + * GET requests are CRC challenges — we respond with the HMAC-SHA256 hash. + * POST requests are webhook events (DMs, tweets, etc.). + */ + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + // Handle CRC challenge (GET request) + if (request.method === "GET") { + return this.handleCrcChallenge(request); + } + + // For POST requests, verify the webhook signature + const body = await request.text(); + + // Parse the payload + let payload: TwitterAccountActivityPayload; + try { + payload = JSON.parse(body) as TwitterAccountActivityPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring Twitter webhook" + ); + return new Response("OK", { status: 200 }); + } + + // Process DM events + try { + this.processDMEvents(payload, options); + } catch (error) { + this.logger.warn("Failed to process Twitter webhook payload", { + error: String(error), + forUserId: payload.for_user_id, + }); + } + + return new Response("OK", { status: 200 }); + } + + /** + * Handle CRC challenge from the X Account Activity API. + * Responds with HMAC-SHA256 hash of the crc_token using consumer secret. + */ + private async handleCrcChallenge(request: Request): Promise { + const url = new URL(request.url); + const crcToken = url.searchParams.get(CRC_TOKEN_PARAM); + + if (!crcToken) { + return new Response("Missing crc_token parameter", { status: 400 }); + } + + const responseToken = await computeHmacSha256( + this.consumerSecret, + crcToken + ); + + return new Response( + JSON.stringify({ + response_token: `sha256=${responseToken}`, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + + /** + * Process DM events from the Account Activity webhook payload. + */ + private processDMEvents( + payload: TwitterAccountActivityPayload, + options?: WebhookOptions + ): void { + if (!this.chat || !payload.direct_message_events) { + return; + } + + for (const dmEvent of payload.direct_message_events) { + if (dmEvent.type !== "message_create") { + continue; + } + + // Skip messages sent by the bot itself to prevent loops + const senderId = dmEvent.message_create.sender_id; + if (senderId === this._botUserId) { + continue; + } + + // Skip messages sent by the bot to the `for_user_id` + // (outbound DMs also appear in the webhook) + if (senderId === payload.for_user_id && senderId === this._botUserId) { + continue; + } + + // Duplicate suppression: if two subscribed users are in the same DM, + // we receive the event twice (once per user). We only process events + // where `for_user_id` matches our bot's user ID. + if (this._botUserId && payload.for_user_id !== this._botUserId) { + continue; + } + + // Determine the conversation ID + // In a 1:1 DM, the conversation ID is derived from the user IDs + const recipientId = dmEvent.message_create.target.recipient_id; + const conversationId = this.deriveConversationId(senderId, recipientId); + + const threadId = this.encodeThreadId({ conversationId }); + + const parsedMessage = this.parseDMEvent( + dmEvent, + threadId, + payload.users + ); + this.cacheMessage(parsedMessage); + + this.chat.processMessage(this, threadId, parsedMessage, options); + } + } + + /** + * Derive a deterministic conversation ID from two user IDs. + * Twitter's 1:1 DM conversation IDs are formed by sorting the two user + * IDs and joining them. For simplicity, we use the smaller ID first. + */ + private deriveConversationId( + userId1: string, + userId2: string + ): string { + const ids = [userId1, userId2].sort(); + return `${ids[0]}-${ids[1]}`; + } + + /** + * Parse a Twitter DM event into a normalized Message. + */ + private parseDMEvent( + dmEvent: TwitterDirectMessageEvent, + threadId: string, + users?: Record + ): Message { + const senderId = dmEvent.message_create.sender_id; + const text = dmEvent.message_create.message_data.text; + const user = users?.[senderId]; + + const author: TwitterMessageAuthor = { + userId: senderId, + userName: user?.screen_name ?? senderId, + fullName: user?.name ?? user?.screen_name ?? senderId, + isBot: senderId === this._botUserId, + isMe: senderId === this._botUserId, + }; + + const attachments = this.extractAttachments(dmEvent); + const isMention = this.checkMention(text); + + return new Message({ + id: dmEvent.id, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw: dmEvent, + author, + metadata: { + dateSent: new Date(Number.parseInt(dmEvent.created_timestamp, 10)), + edited: false, + }, + attachments, + isMention, + }); + } + + /** + * Extract attachments from a DM event. + */ + private extractAttachments( + dmEvent: TwitterDirectMessageEvent + ): Attachment[] { + const attachments: Attachment[] = []; + const attachment = dmEvent.message_create.message_data.attachment; + + if (attachment?.media) { + const media = attachment.media; + const type = media.type === "video" ? "video" : "image"; + + // Find the largest image size + const largestSize = media.sizes + ? Object.values(media.sizes).reduce( + (acc, size) => + size.w * size.h > (acc?.w ?? 0) * (acc?.h ?? 0) ? size : acc, + undefined as { w: number; h: number; resize: string } | undefined + ) + : undefined; + + attachments.push({ + type, + url: media.media_url_https, + width: largestSize?.w, + height: largestSize?.h, + }); + } + + return attachments; + } + + /** + * Check if the bot is mentioned in the text. + */ + private checkMention(text: string): boolean { + if (!text || !this._userName) { + return false; + } + + const mentionPattern = new RegExp( + `@${this.escapeRegex(this._userName)}\\b`, + "i" + ); + return mentionPattern.test(text); + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { conversationId } = this.resolveThreadId(threadId); + + const card = extractCard(message); + const text = this.truncateMessage( + card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message) + ); + + if (!text.trim()) { + throw new ValidationError("twitter", "Message text cannot be empty"); + } + + // Determine the recipient from the conversation ID + const recipientId = this.getRecipientFromConversation(conversationId); + + const response = await this.sendDM(recipientId, text); + + // Create a synthetic raw DM event for the sent message + const syntheticEvent: TwitterDirectMessageEvent = { + type: "message_create", + id: response.dm_event_id, + created_timestamp: String(Date.now()), + message_create: { + target: { recipient_id: recipientId }, + sender_id: this._botUserId ?? "", + message_data: { text }, + }, + }; + + const resultThreadId = this.encodeThreadId({ + conversationId: response.dm_conversation_id, + }); + + const parsedMessage = new Message({ + id: response.dm_event_id, + threadId: resultThreadId, + text, + formatted: this.formatConverter.toAst(text), + raw: syntheticEvent, + author: { + userId: this._botUserId ?? "", + userName: this._userName, + fullName: this._userName, + isBot: true, + isMe: true, + }, + metadata: { + dateSent: new Date(), + edited: false, + }, + attachments: [], + }); + + this.cacheMessage(parsedMessage); + + return { + id: parsedMessage.id, + threadId: parsedMessage.threadId, + raw: syntheticEvent, + }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new NotImplementedError( + "Twitter DMs cannot be edited after sending", + "editMessage" + ); + } + + async deleteMessage( + _threadId: string, + messageId: string + ): Promise { + // Twitter API v2 supports deleting DM events + await this.twitterFetchOAuth( + `/2/dm_conversations/events/${messageId}`, + "DELETE" + ); + + this.deleteCachedMessage(messageId); + } + + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + // Twitter DMs do support reactions, but the API is limited. + // For now, log a warning that this is not fully supported. + this.logger.warn( + "Twitter DM reactions via API are not fully supported" + ); + } + + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + this.logger.warn( + "Twitter DM reaction removal via API is not fully supported" + ); + } + + async startTyping(_threadId: string): Promise { + // Twitter DM API doesn't have a native typing indicator endpoint. + // No-op to fulfill the interface contract. + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const messages = [ + ...(this.messageCache.get(threadId) ?? []), + ].sort((a, b) => this.compareMessages(a, b)); + + return this.paginateMessages(messages, options); + } + + async fetchThread(threadId: string): Promise { + const { conversationId } = this.resolveThreadId(threadId); + + return { + id: threadId, + channelId: conversationId, + isDM: true, + metadata: { + conversationId, + }, + }; + } + + channelIdFromThreadId(threadId: string): string { + const { conversationId } = this.resolveThreadId(threadId); + return `twitter:${conversationId}`; + } + + async openDM(userId: string): Promise { + if (!this._botUserId) { + throw new ValidationError( + "twitter", + "Bot user ID is not available. Ensure the adapter is initialized." + ); + } + + const conversationId = this.deriveConversationId( + this._botUserId, + userId + ); + return this.encodeThreadId({ conversationId }); + } + + isDM(_threadId: string): boolean { + // All Twitter adapter threads are DM conversations + return true; + } + + encodeThreadId(platformData: TwitterThreadId): string { + return `twitter:${platformData.conversationId}`; + } + + decodeThreadId(threadId: string): TwitterThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "twitter" || parts.length !== 2) { + throw new ValidationError( + "twitter", + `Invalid Twitter thread ID: ${threadId}` + ); + } + + const conversationId = parts[1]; + if (!conversationId) { + throw new ValidationError( + "twitter", + `Invalid Twitter thread ID: ${threadId}` + ); + } + + return { conversationId }; + } + + parseMessage(raw: TwitterRawMessage): Message { + const senderId = raw.message_create.sender_id; + const recipientId = raw.message_create.target.recipient_id; + const conversationId = this.deriveConversationId(senderId, recipientId); + const threadId = this.encodeThreadId({ conversationId }); + + const message = this.parseDMEvent(raw, threadId); + this.cacheMessage(message); + return message; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + // ─── Private Helpers ────────────────────────────────────────────────── + + private resolveThreadId(value: string): TwitterThreadId { + if (value.startsWith("twitter:")) { + return this.decodeThreadId(value); + } + return { conversationId: value }; + } + + /** + * Get the recipient user ID from a conversation ID. + * Conversation IDs are in the format `{smallerId}-{largerId}`. + * The recipient is the ID that is NOT the bot's. + */ + private getRecipientFromConversation(conversationId: string): string { + const parts = conversationId.split("-"); + if (parts.length !== 2) { + throw new ValidationError( + "twitter", + `Cannot determine recipient from conversation ID: ${conversationId}` + ); + } + + const [id1, id2] = parts; + if (id1 === this._botUserId) { + return id2; + } + if (id2 === this._botUserId) { + return id1; + } + + // If bot user ID is unknown, default to the second ID + return id2; + } + + /** + * Send a DM via the Twitter API v2. + */ + private async sendDM( + recipientId: string, + text: string + ): Promise<{ dm_event_id: string; dm_conversation_id: string }> { + const url = `${this.apiBaseUrl}/2/dm_conversations/with/${recipientId}/messages`; + + const response = await this.twitterFetchOAuth(url, "POST", { text }); + + const data = response as { data?: { dm_event_id: string; dm_conversation_id: string } }; + if (!data.data) { + throw new NetworkError( + "twitter", + "Twitter DM API returned no data" + ); + } + + return data.data; + } + + /** + * Make an authenticated API call using OAuth 1.0a. + */ + private async twitterFetchOAuth( + urlOrPath: string, + method: string, + body?: Record + ): Promise { + const url = urlOrPath.startsWith("http") + ? urlOrPath + : `${this.apiBaseUrl}${urlOrPath}`; + + const authHeader = await generateOAuth1Header({ + method, + url, + consumerKey: this.consumerKey, + consumerSecret: this.consumerSecret, + accessToken: this.accessToken, + accessTokenSecret: this.accessTokenSecret, + }); + + let response: Response; + try { + response = await fetch(url, { + method, + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (error) { + throw new NetworkError( + "twitter", + `Network error calling Twitter API: ${method} ${urlOrPath}`, + error instanceof Error ? error : undefined + ); + } + + if (response.status === 204) { + return {}; + } + + let data: unknown; + try { + data = await response.json(); + } catch { + throw new NetworkError( + "twitter", + `Failed to parse Twitter API response for ${method} ${urlOrPath}` + ); + } + + if (!response.ok) { + this.throwTwitterApiError(method, urlOrPath, response.status, data); + } + + return data; + } + + /** + * Make an authenticated API call using Bearer Token (API v2 read endpoints). + */ + private async twitterFetchV2( + path: string, + method: string, + queryParams?: Record + ): Promise { + const url = new URL(`${this.apiBaseUrl}${path}`); + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value); + } + } + + let response: Response; + try { + response = await fetch(url.toString(), { + method, + headers: { + Authorization: `Bearer ${this.bearerToken}`, + }, + }); + } catch (error) { + throw new NetworkError( + "twitter", + `Network error calling Twitter API: ${method} ${path}`, + error instanceof Error ? error : undefined + ); + } + + let data: TwitterApiV2Response; + try { + data = (await response.json()) as TwitterApiV2Response; + } catch { + throw new NetworkError( + "twitter", + `Failed to parse Twitter API v2 response for ${method} ${path}` + ); + } + + if (!response.ok) { + this.throwTwitterApiError(method, path, response.status, data); + } + + return data.data ?? null; + } + + /** + * Map HTTP status codes to appropriate error classes. + */ + private throwTwitterApiError( + method: string, + path: string, + status: number, + data: unknown + ): never { + const errors = (data as { errors?: Array<{ message?: string; detail?: string }> }) + ?.errors; + const firstError = errors?.[0]; + const description = + firstError?.detail ?? + firstError?.message ?? + `Twitter API ${method} ${path} failed`; + + if (status === 429) { + throw new AdapterRateLimitError("twitter"); + } + + if (status === 401) { + throw new AuthenticationError("twitter", description); + } + + if (status === 403) { + throw new PermissionError("twitter", `${method} ${path}`); + } + + if (status === 404) { + throw new ResourceNotFoundError("twitter", path); + } + + if (status >= 400 && status < 500) { + throw new ValidationError("twitter", description); + } + + throw new NetworkError( + "twitter", + `${description} (status ${status})` + ); + } + + private truncateMessage(text: string): string { + if (text.length <= TWITTER_DM_MESSAGE_LIMIT) { + return text; + } + return `${text.slice(0, TWITTER_DM_MESSAGE_LIMIT - 3)}...`; + } + + private escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + + private cacheMessage(message: Message): void { + const existing = this.messageCache.get(message.threadId) ?? []; + const index = existing.findIndex((item) => item.id === message.id); + + if (index >= 0) { + existing[index] = message; + } else { + existing.push(message); + } + + existing.sort((a, b) => this.compareMessages(a, b)); + this.messageCache.set(message.threadId, existing); + } + + private findCachedMessage( + messageId: string + ): Message | undefined { + for (const messages of this.messageCache.values()) { + const found = messages.find((message) => message.id === messageId); + if (found) { + return found; + } + } + return undefined; + } + + private deleteCachedMessage(messageId: string): void { + for (const [threadId, messages] of this.messageCache.entries()) { + const filtered = messages.filter( + (message) => message.id !== messageId + ); + if (filtered.length === 0) { + this.messageCache.delete(threadId); + } else if (filtered.length !== messages.length) { + this.messageCache.set(threadId, filtered); + } + } + } + + private compareMessages( + a: Message, + b: Message + ): number { + return ( + a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime() + ); + } + + private paginateMessages( + messages: Message[], + options: FetchOptions + ): FetchResult { + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + const direction = options.direction ?? "backward"; + + if (messages.length === 0) { + return { messages: [] }; + } + + const messageIndexById = new Map( + messages.map((message, index) => [message.id, index]) + ); + + if (direction === "backward") { + const end = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? messages.length) + : messages.length; + const start = Math.max(0, end - limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: start > 0 ? page[0]?.id : undefined, + }; + } + + const start = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? -1) + 1 + : 0; + const end = Math.min(messages.length, start + limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: end < messages.length ? page.at(-1)?.id : undefined, + }; + } +} + +export function createTwitterAdapter( + config?: TwitterAdapterConfig +): TwitterAdapter { + return new TwitterAdapter(config ?? {}); +} + +export { TwitterFormatConverter } from "./markdown"; +export type { + TwitterAccountActivityPayload, + TwitterAdapterConfig, + TwitterDirectMessageEvent, + TwitterDMEventV2, + TwitterDMSendResponse, + TwitterRawMessage, + TwitterThreadId, + TwitterUser, + TwitterUserV2, +} from "./types"; diff --git a/packages/adapter-twitter/src/markdown.test.ts b/packages/adapter-twitter/src/markdown.test.ts new file mode 100644 index 00000000..4b5b9496 --- /dev/null +++ b/packages/adapter-twitter/src/markdown.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { TwitterFormatConverter } from "./markdown"; + +describe("TwitterFormatConverter", () => { + const converter = new TwitterFormatConverter(); + + 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 markdown bold", () => { + const ast = converter.toAst("This is **bold** text"); + expect(ast.type).toBe("root"); + }); + + it("should parse markdown links", () => { + const ast = converter.toAst("Check [this](https://example.com)"); + expect(ast.type).toBe("root"); + }); + + it("should handle empty text", () => { + const ast = converter.toAst(""); + expect(ast.type).toBe("root"); + }); + + it("should handle @mentions in text", () => { + const ast = converter.toAst("Hello @user123 how are you?"); + expect(ast.type).toBe("root"); + }); + }); + + describe("fromAst", () => { + it("should convert AST back to text", () => { + const ast = converter.toAst("Hello world"); + const result = converter.fromAst(ast); + expect(result).toBe("Hello world"); + }); + + it("should convert bold text", () => { + const ast = converter.toAst("This is **bold** text"); + const result = converter.fromAst(ast); + expect(result).toContain("bold"); + }); + }); + + describe("renderPostable", () => { + it("should render string messages directly", () => { + const result = converter.renderPostable("Hello world"); + expect(result).toBe("Hello world"); + }); + + it("should render raw messages", () => { + const result = converter.renderPostable({ raw: "raw content" }); + expect(result).toBe("raw content"); + }); + + it("should render markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toContain("bold"); + }); + }); +}); diff --git a/packages/adapter-twitter/src/markdown.ts b/packages/adapter-twitter/src/markdown.ts new file mode 100644 index 00000000..4b69c5a9 --- /dev/null +++ b/packages/adapter-twitter/src/markdown.ts @@ -0,0 +1,65 @@ +/** + * Twitter / X format conversion. + * + * Twitter DMs use plain text with entity annotations (similar to Telegram). + * For outbound messages, we send plain text since the DM API doesn't + * support rich formatting (bold/italic/etc.) natively. + */ + +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class TwitterFormatConverter extends BaseFormatConverter { + /** + * Convert platform text to mdast AST. + * Twitter DMs are plain text — we parse as if they were markdown + * so that any user-typed markdown is preserved in the AST. + */ + toAst(text: string): Root { + return parseMarkdown(text); + } + + /** + * Convert mdast AST to platform text format. + * Twitter DMs don't support rich formatting, so we try to + * produce readable plain text. Tables get converted to ASCII art. + */ + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + return stringifyMarkdown(transformed).trim(); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } +} diff --git a/packages/adapter-twitter/src/types.ts b/packages/adapter-twitter/src/types.ts new file mode 100644 index 00000000..975fdae6 --- /dev/null +++ b/packages/adapter-twitter/src/types.ts @@ -0,0 +1,251 @@ +/** + * Twitter / X adapter types. + */ + +import type { Logger } from "chat"; + +/** + * Twitter adapter configuration. + */ +export interface TwitterAdapterConfig { + /** + * Twitter API v2 Bearer Token for read-only endpoints. + * Defaults to TWITTER_BEARER_TOKEN env var. + */ + bearerToken?: string; + + /** + * Twitter API consumer key (API Key). + * Used for OAuth 1.0a signing and CRC validation. + * Defaults to TWITTER_CONSUMER_KEY env var. + */ + consumerKey?: string; + + /** + * Twitter API consumer secret (API Secret). + * Used for CRC HMAC-SHA256 computation. + * Defaults to TWITTER_CONSUMER_SECRET env var. + */ + consumerSecret?: string; + + /** + * OAuth 1.0a access token for the bot account. + * Defaults to TWITTER_ACCESS_TOKEN env var. + */ + accessToken?: string; + + /** + * OAuth 1.0a access token secret for the bot account. + * Defaults to TWITTER_ACCESS_TOKEN_SECRET env var. + */ + accessTokenSecret?: string; + + /** Logger instance for error reporting. Defaults to ConsoleLogger. */ + logger?: Logger; + + /** Override bot username (optional). Defaults to TWITTER_BOT_USERNAME env var. */ + userName?: string; + + /** + * Optional custom API base URL (defaults to https://api.twitter.com). + * Useful for testing with a mock server. + */ + apiBaseUrl?: string; + + /** + * Optional webhook environment name for Account Activity API. + * Defaults to TWITTER_WEBHOOK_ENV env var or "production". + */ + webhookEnvironment?: string; +} + +/** + * Twitter thread ID components. + * A "thread" in Twitter maps to a DM conversation. + */ +export interface TwitterThreadId { + /** DM conversation ID */ + conversationId: string; +} + +/** + * Twitter user object from the Account Activity API webhook payload. + */ +export interface TwitterUser { + id: string; + created_timestamp: string; + name: string; + screen_name: string; + location?: string; + description?: string; + url?: string; + protected: boolean; + verified: boolean; + followers_count: number; + friends_count: number; + statuses_count: number; + profile_image_url_https?: string; +} + +/** + * Twitter DM event message data. + */ +export interface TwitterMessageData { + text: string; + entities?: { + hashtags: TwitterEntity[]; + symbols: TwitterEntity[]; + user_mentions: TwitterMentionEntity[]; + urls: TwitterUrlEntity[]; + }; + attachment?: { + type: string; + media: TwitterMedia; + }; +} + +export interface TwitterEntity { + text?: string; + indices: [number, number]; +} + +export interface TwitterMentionEntity extends TwitterEntity { + id: number; + id_str: string; + name: string; + screen_name: string; +} + +export interface TwitterUrlEntity extends TwitterEntity { + url: string; + expanded_url: string; + display_url: string; +} + +export interface TwitterMedia { + id: number; + id_str: string; + media_url: string; + media_url_https: string; + type: string; + sizes?: Record< + string, + { + w: number; + h: number; + resize: string; + } + >; +} + +/** + * A single DM event within the Account Activity webhook payload. + */ +export interface TwitterDirectMessageEvent { + type: "message_create"; + id: string; + created_timestamp: string; + message_create: { + target: { + recipient_id: string; + }; + sender_id: string; + source_app_id?: string; + message_data: TwitterMessageData; + }; +} + +/** + * Account Activity API webhook payload for DM events. + */ +export interface TwitterDMWebhookPayload { + for_user_id: string; + direct_message_events: TwitterDirectMessageEvent[]; + users?: Record; + apps?: Record; +} + +/** + * Twitter API v2 DM event from GET /2/direct_messages/events. + */ +export interface TwitterDMEventV2 { + id: string; + event_type: "MessageCreate" | "ParticipantsJoin" | "ParticipantsLeave"; + text: string; + dm_conversation_id?: string; + created_at?: string; + sender_id?: string; + participant_ids?: string[]; + attachments?: { + media_keys?: string[]; + }; + referenced_tweets?: Array<{ + id: string; + type: string; + }>; +} + +/** + * Response envelope for Twitter API v2 DM send. + */ +export interface TwitterDMSendResponse { + dm_conversation_id: string; + dm_event_id: string; +} + +/** + * Response envelope for Twitter API v2 endpoints. + */ +export interface TwitterApiV2Response { + data?: TData; + errors?: TwitterApiError[]; + includes?: Record; + meta?: Record; +} + +/** + * Twitter API error object. + */ +export interface TwitterApiError { + message: string; + code?: number; + title?: string; + detail?: string; + type?: string; + status?: number; +} + +/** + * Twitter API v2 user object. + */ +export interface TwitterUserV2 { + id: string; + name: string; + username: string; + profile_image_url?: string; + verified?: boolean; +} + +/** + * Full webhook payload from Account Activity API. + * Can contain DM events, tweet events, follow events, etc. + * We focus on DM events for the adapter. + */ +export interface TwitterAccountActivityPayload { + for_user_id: string; + direct_message_events?: TwitterDirectMessageEvent[]; + users?: Record; + apps?: Record; + // Other activity types we acknowledge but don't process: + tweet_create_events?: unknown[]; + favorite_events?: unknown[]; + follow_events?: unknown[]; + block_events?: unknown[]; + mute_events?: unknown[]; +} + +/** + * Raw message type for the Twitter adapter. + * This is the DM event as received from the webhook. + */ +export type TwitterRawMessage = TwitterDirectMessageEvent; diff --git a/packages/adapter-twitter/tsconfig.json b/packages/adapter-twitter/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-twitter/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-twitter/tsup.config.ts b/packages/adapter-twitter/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-twitter/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-twitter/vitest.config.ts b/packages/adapter-twitter/vitest.config.ts new file mode 100644 index 00000000..2851f225 --- /dev/null +++ b/packages/adapter-twitter/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e123c248..6a809e2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -457,6 +457,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-twitter: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-whatsapp: dependencies: '@chat-adapter/shared': diff --git a/vitest.config.ts b/vitest.config.ts index 9a11f035..b86f29e8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ "packages/adapter-shared", "packages/adapter-slack", "packages/adapter-teams", + "packages/adapter-telegram", + "packages/adapter-twitter", + "packages/adapter-whatsapp", "packages/state-ioredis", "packages/state-memory", "packages/state-redis",