xbird-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of xbird-mcp might be problematic. Click here for more details.
- package/docs/listing.md +42 -0
- package/docs/mcp-setup.md +146 -0
- package/package.json +50 -0
- package/src/cli/context.ts +80 -0
- package/src/cli/output.ts +33 -0
- package/src/cli/program.ts +63 -0
- package/src/cli.ts +8 -0
- package/src/commands/about.ts +30 -0
- package/src/commands/bookmark.ts +40 -0
- package/src/commands/bookmarks-list.ts +108 -0
- package/src/commands/check.ts +27 -0
- package/src/commands/engagement.ts +31 -0
- package/src/commands/follow.ts +40 -0
- package/src/commands/followers.ts +132 -0
- package/src/commands/home.ts +59 -0
- package/src/commands/likes.ts +73 -0
- package/src/commands/lists.ts +101 -0
- package/src/commands/mentions.ts +32 -0
- package/src/commands/news.ts +38 -0
- package/src/commands/read.ts +36 -0
- package/src/commands/replies.ts +59 -0
- package/src/commands/search.ts +60 -0
- package/src/commands/tweet.ts +128 -0
- package/src/commands/user-tweets.ts +73 -0
- package/src/commands/user.ts +20 -0
- package/src/commands/whoami.ts +26 -0
- package/src/formatters/common.ts +49 -0
- package/src/formatters/list.ts +22 -0
- package/src/formatters/news.ts +23 -0
- package/src/formatters/tweet.ts +38 -0
- package/src/formatters/user.ts +20 -0
- package/src/index.ts +26 -0
- package/src/lib/auth.ts +105 -0
- package/src/lib/client-bookmarks.ts +62 -0
- package/src/lib/client-engagement.ts +152 -0
- package/src/lib/client-follow.ts +90 -0
- package/src/lib/client-full.ts +48 -0
- package/src/lib/client-likes.ts +62 -0
- package/src/lib/client-lists.ts +227 -0
- package/src/lib/client-media.ts +162 -0
- package/src/lib/client-news.ts +185 -0
- package/src/lib/client-posting.ts +163 -0
- package/src/lib/client-reading.ts +452 -0
- package/src/lib/client-search.ts +156 -0
- package/src/lib/client-timeline.ts +98 -0
- package/src/lib/client-user.ts +518 -0
- package/src/lib/client.ts +134 -0
- package/src/lib/config.ts +22 -0
- package/src/lib/constants.ts +55 -0
- package/src/lib/cookies.ts +132 -0
- package/src/lib/features.ts +175 -0
- package/src/lib/headers.ts +28 -0
- package/src/lib/paginate.ts +39 -0
- package/src/lib/query-ids.ts +190 -0
- package/src/lib/types.ts +147 -0
- package/src/lib/utils.ts +176 -0
- package/src/mcp/executor.ts +178 -0
- package/src/mcp/payment.ts +38 -0
- package/src/mcp/server.ts +53 -0
- package/src/mcp/tools.ts +389 -0
- package/src/server/app.ts +117 -0
- package/src/server/config/accounts.ts +137 -0
- package/src/server/config/pricing.ts +217 -0
- package/src/server/erc8004/register.ts +77 -0
- package/src/server/middleware/account-pool.ts +101 -0
- package/src/server/middleware/error-handler.ts +27 -0
- package/src/server/middleware/payer-extract.ts +43 -0
- package/src/server/middleware/x402.ts +61 -0
- package/src/server/routes/accounts.ts +93 -0
- package/src/server/routes/authorize.ts +19 -0
- package/src/server/routes/bookmarks.ts +21 -0
- package/src/server/routes/engagement.ts +84 -0
- package/src/server/routes/follow.ts +32 -0
- package/src/server/routes/health.ts +14 -0
- package/src/server/routes/lists.ts +38 -0
- package/src/server/routes/media.ts +44 -0
- package/src/server/routes/mentions.ts +20 -0
- package/src/server/routes/news.ts +23 -0
- package/src/server/routes/search.ts +26 -0
- package/src/server/routes/timeline.ts +21 -0
- package/src/server/routes/tweets.ts +82 -0
- package/src/server/routes/users.ts +92 -0
- package/src/server/storage/accounts-db.ts +84 -0
- package/src/server.ts +34 -0
- package/tsconfig.json +29 -0
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { PaidExecutor } from "./executor.ts";
|
|
4
|
+
|
|
5
|
+
export function registerTools(server: McpServer, executor: PaidExecutor): void {
|
|
6
|
+
// ── Read tier ($0.001) ──────────────────────────────────
|
|
7
|
+
|
|
8
|
+
server.tool(
|
|
9
|
+
"get_tweet",
|
|
10
|
+
"Get a tweet by ID",
|
|
11
|
+
{ id: z.string().describe("Tweet ID") },
|
|
12
|
+
async ({ id }) => {
|
|
13
|
+
const result = await executor.getTweet(id);
|
|
14
|
+
return toMcpResult(result);
|
|
15
|
+
}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
server.tool(
|
|
19
|
+
"get_thread",
|
|
20
|
+
"Get a tweet thread (conversation chain)",
|
|
21
|
+
{ id: z.string().describe("Tweet ID of any tweet in the thread") },
|
|
22
|
+
async ({ id }) => {
|
|
23
|
+
const result = await executor.getThread(id);
|
|
24
|
+
return toMcpResult(result);
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
server.tool(
|
|
29
|
+
"get_replies",
|
|
30
|
+
"Get replies to a tweet",
|
|
31
|
+
{
|
|
32
|
+
id: z.string().describe("Tweet ID"),
|
|
33
|
+
count: z.number().optional().describe("Number of replies (default 20)"),
|
|
34
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
35
|
+
},
|
|
36
|
+
async ({ id, count, cursor }) => {
|
|
37
|
+
const result = await executor.getReplies(id, count, cursor);
|
|
38
|
+
return toMcpResult(result);
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
server.tool(
|
|
43
|
+
"get_user",
|
|
44
|
+
"Get a Twitter user profile by handle",
|
|
45
|
+
{ handle: z.string().describe("Twitter handle (without @)") },
|
|
46
|
+
async ({ handle }) => {
|
|
47
|
+
const result = await executor.getUserByHandle(handle);
|
|
48
|
+
return toMcpResult(result);
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
server.tool(
|
|
53
|
+
"get_user_about",
|
|
54
|
+
"Get detailed about info for a user",
|
|
55
|
+
{ handle: z.string().describe("Twitter handle (without @)") },
|
|
56
|
+
async ({ handle }) => {
|
|
57
|
+
const result = await executor.getAbout(handle);
|
|
58
|
+
return toMcpResult(result);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.tool(
|
|
63
|
+
"get_current_user",
|
|
64
|
+
"Get the authenticated user's profile",
|
|
65
|
+
{},
|
|
66
|
+
async () => {
|
|
67
|
+
const result = await executor.getCurrentUser();
|
|
68
|
+
return toMcpResult(result);
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
server.tool(
|
|
73
|
+
"get_home_timeline",
|
|
74
|
+
"Get the home timeline (latest tweets from followed accounts)",
|
|
75
|
+
{
|
|
76
|
+
count: z.number().optional().describe("Number of tweets (default 20)"),
|
|
77
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
78
|
+
},
|
|
79
|
+
async ({ count, cursor }) => {
|
|
80
|
+
const result = await executor.home(count, cursor);
|
|
81
|
+
return toMcpResult(result);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.tool(
|
|
86
|
+
"get_news",
|
|
87
|
+
"Get trending news/topics on Twitter",
|
|
88
|
+
{
|
|
89
|
+
count: z.number().optional().describe("Number of items"),
|
|
90
|
+
tab: z.string().optional().describe("News tab: trending, forYou, news, sports, entertainment"),
|
|
91
|
+
aiOnly: z.boolean().optional().describe("Only AI-generated summaries"),
|
|
92
|
+
},
|
|
93
|
+
async ({ count, tab, aiOnly }) => {
|
|
94
|
+
const opts = tab || aiOnly !== undefined ? { tab, aiOnly } : undefined;
|
|
95
|
+
const result = await executor.getNews(count, opts);
|
|
96
|
+
return toMcpResult(result);
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.tool(
|
|
101
|
+
"get_lists",
|
|
102
|
+
"Get owned Twitter lists",
|
|
103
|
+
{},
|
|
104
|
+
async () => {
|
|
105
|
+
const result = await executor.getOwnedLists();
|
|
106
|
+
return toMcpResult(result);
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
server.tool(
|
|
111
|
+
"get_list_timeline",
|
|
112
|
+
"Get tweets from a list",
|
|
113
|
+
{
|
|
114
|
+
id: z.string().describe("List ID"),
|
|
115
|
+
count: z.number().optional().describe("Number of tweets (default 20)"),
|
|
116
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
117
|
+
},
|
|
118
|
+
async ({ id, count, cursor }) => {
|
|
119
|
+
const result = await executor.getListTimeline(id, count, cursor);
|
|
120
|
+
return toMcpResult(result);
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// ── Search tier ($0.005) ────────────────────────────────
|
|
125
|
+
|
|
126
|
+
server.tool(
|
|
127
|
+
"search_tweets",
|
|
128
|
+
"Search for tweets",
|
|
129
|
+
{
|
|
130
|
+
query: z.string().describe("Search query"),
|
|
131
|
+
count: z.number().optional().describe("Number of results (default 20)"),
|
|
132
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
133
|
+
},
|
|
134
|
+
async ({ query, count, cursor }) => {
|
|
135
|
+
const result = await executor.search(query, count, cursor);
|
|
136
|
+
return toMcpResult(result);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
server.tool(
|
|
141
|
+
"get_mentions",
|
|
142
|
+
"Get mentions for a user",
|
|
143
|
+
{
|
|
144
|
+
handle: z.string().describe("Twitter handle (without @)"),
|
|
145
|
+
count: z.number().optional().describe("Number of mentions"),
|
|
146
|
+
},
|
|
147
|
+
async ({ handle, count }) => {
|
|
148
|
+
const result = await executor.mentions(handle, count);
|
|
149
|
+
return toMcpResult(result);
|
|
150
|
+
}
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ── Bulk tier ($0.01) ───────────────────────────────────
|
|
154
|
+
|
|
155
|
+
server.tool(
|
|
156
|
+
"get_user_tweets",
|
|
157
|
+
"Get tweets posted by a user",
|
|
158
|
+
{
|
|
159
|
+
userId: z.string().describe("User ID (numeric)"),
|
|
160
|
+
count: z.number().optional().describe("Number of tweets (default 20)"),
|
|
161
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
162
|
+
},
|
|
163
|
+
async ({ userId, count, cursor }) => {
|
|
164
|
+
const result = await executor.getUserTweets(userId, count, cursor);
|
|
165
|
+
return toMcpResult(result);
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
server.tool(
|
|
170
|
+
"get_followers",
|
|
171
|
+
"Get a user's followers",
|
|
172
|
+
{
|
|
173
|
+
userId: z.string().describe("User ID (numeric)"),
|
|
174
|
+
count: z.number().optional().describe("Number of followers (default 20)"),
|
|
175
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
176
|
+
},
|
|
177
|
+
async ({ userId, count, cursor }) => {
|
|
178
|
+
const result = await executor.getFollowers(userId, count, cursor);
|
|
179
|
+
return toMcpResult(result);
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
server.tool(
|
|
184
|
+
"get_following",
|
|
185
|
+
"Get users that a user follows",
|
|
186
|
+
{
|
|
187
|
+
userId: z.string().describe("User ID (numeric)"),
|
|
188
|
+
count: z.number().optional().describe("Number of users (default 20)"),
|
|
189
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
190
|
+
},
|
|
191
|
+
async ({ userId, count, cursor }) => {
|
|
192
|
+
const result = await executor.getFollowing(userId, count, cursor);
|
|
193
|
+
return toMcpResult(result);
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
server.tool(
|
|
198
|
+
"get_likes",
|
|
199
|
+
"Get tweets liked by a user",
|
|
200
|
+
{
|
|
201
|
+
userId: z.string().describe("User ID (numeric)"),
|
|
202
|
+
count: z.number().optional().describe("Number of liked tweets (default 20)"),
|
|
203
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
204
|
+
},
|
|
205
|
+
async ({ userId, count, cursor }) => {
|
|
206
|
+
const result = await executor.getLikes(userId, count, cursor);
|
|
207
|
+
return toMcpResult(result);
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
server.tool(
|
|
212
|
+
"get_bookmarks",
|
|
213
|
+
"Get bookmarked tweets",
|
|
214
|
+
{
|
|
215
|
+
count: z.number().optional().describe("Number of bookmarks (default 20)"),
|
|
216
|
+
cursor: z.string().optional().describe("Pagination cursor"),
|
|
217
|
+
folderId: z.string().optional().describe("Bookmark folder ID"),
|
|
218
|
+
},
|
|
219
|
+
async ({ count, cursor, folderId }) => {
|
|
220
|
+
const result = await executor.getBookmarks(count, cursor, folderId);
|
|
221
|
+
return toMcpResult(result);
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
server.tool(
|
|
226
|
+
"get_list_memberships",
|
|
227
|
+
"Get lists the authenticated user is a member of",
|
|
228
|
+
{},
|
|
229
|
+
async () => {
|
|
230
|
+
const result = await executor.getListMemberships();
|
|
231
|
+
return toMcpResult(result);
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// ── Write tier ($0.01) ──────────────────────────────────
|
|
236
|
+
|
|
237
|
+
server.tool(
|
|
238
|
+
"post_tweet",
|
|
239
|
+
"Post a new tweet",
|
|
240
|
+
{
|
|
241
|
+
text: z.string().describe("Tweet text"),
|
|
242
|
+
mediaIds: z.array(z.string()).optional().describe("Media IDs to attach"),
|
|
243
|
+
},
|
|
244
|
+
async ({ text, mediaIds }) => {
|
|
245
|
+
const result = await executor.tweet(text, mediaIds);
|
|
246
|
+
return toMcpResult(result);
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
server.tool(
|
|
251
|
+
"reply_to_tweet",
|
|
252
|
+
"Reply to a tweet",
|
|
253
|
+
{
|
|
254
|
+
text: z.string().describe("Reply text"),
|
|
255
|
+
replyToId: z.string().describe("Tweet ID to reply to"),
|
|
256
|
+
mediaIds: z.array(z.string()).optional().describe("Media IDs to attach"),
|
|
257
|
+
},
|
|
258
|
+
async ({ text, replyToId, mediaIds }) => {
|
|
259
|
+
const result = await executor.reply(text, replyToId, mediaIds);
|
|
260
|
+
return toMcpResult(result);
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
server.tool(
|
|
265
|
+
"post_thread",
|
|
266
|
+
"Post a thread (multiple tweets in sequence)",
|
|
267
|
+
{
|
|
268
|
+
texts: z.array(z.string()).min(2).describe("Array of tweet texts (each becomes one tweet in the thread)"),
|
|
269
|
+
},
|
|
270
|
+
async ({ texts }) => {
|
|
271
|
+
const result = await executor.thread(texts);
|
|
272
|
+
return toMcpResult(result);
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
server.tool(
|
|
277
|
+
"like_tweet",
|
|
278
|
+
"Like a tweet",
|
|
279
|
+
{ tweetId: z.string().describe("Tweet ID to like") },
|
|
280
|
+
async ({ tweetId }) => {
|
|
281
|
+
const result = await executor.like(tweetId);
|
|
282
|
+
return toMcpResult(result);
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
server.tool(
|
|
287
|
+
"unlike_tweet",
|
|
288
|
+
"Unlike a tweet",
|
|
289
|
+
{ tweetId: z.string().describe("Tweet ID to unlike") },
|
|
290
|
+
async ({ tweetId }) => {
|
|
291
|
+
const result = await executor.unlike(tweetId);
|
|
292
|
+
return toMcpResult(result);
|
|
293
|
+
}
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
server.tool(
|
|
297
|
+
"retweet",
|
|
298
|
+
"Retweet a tweet",
|
|
299
|
+
{ tweetId: z.string().describe("Tweet ID to retweet") },
|
|
300
|
+
async ({ tweetId }) => {
|
|
301
|
+
const result = await executor.retweet(tweetId);
|
|
302
|
+
return toMcpResult(result);
|
|
303
|
+
}
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
server.tool(
|
|
307
|
+
"unretweet",
|
|
308
|
+
"Remove a retweet",
|
|
309
|
+
{ tweetId: z.string().describe("Tweet ID to unretweet") },
|
|
310
|
+
async ({ tweetId }) => {
|
|
311
|
+
const result = await executor.unretweet(tweetId);
|
|
312
|
+
return toMcpResult(result);
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
server.tool(
|
|
317
|
+
"bookmark_tweet",
|
|
318
|
+
"Bookmark a tweet",
|
|
319
|
+
{ tweetId: z.string().describe("Tweet ID to bookmark") },
|
|
320
|
+
async ({ tweetId }) => {
|
|
321
|
+
const result = await executor.bookmark(tweetId);
|
|
322
|
+
return toMcpResult(result);
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
server.tool(
|
|
327
|
+
"unbookmark_tweet",
|
|
328
|
+
"Remove a tweet bookmark",
|
|
329
|
+
{ tweetId: z.string().describe("Tweet ID to unbookmark") },
|
|
330
|
+
async ({ tweetId }) => {
|
|
331
|
+
const result = await executor.unbookmark(tweetId);
|
|
332
|
+
return toMcpResult(result);
|
|
333
|
+
}
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.tool(
|
|
337
|
+
"follow_user",
|
|
338
|
+
"Follow a Twitter user",
|
|
339
|
+
{ handle: z.string().describe("Twitter handle to follow (without @)") },
|
|
340
|
+
async ({ handle }) => {
|
|
341
|
+
const result = await executor.follow(handle);
|
|
342
|
+
return toMcpResult(result);
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
server.tool(
|
|
347
|
+
"unfollow_user",
|
|
348
|
+
"Unfollow a Twitter user",
|
|
349
|
+
{ handle: z.string().describe("Twitter handle to unfollow (without @)") },
|
|
350
|
+
async ({ handle }) => {
|
|
351
|
+
const result = await executor.unfollow(handle);
|
|
352
|
+
return toMcpResult(result);
|
|
353
|
+
}
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// ── Media tier ($0.05) ──────────────────────────────────
|
|
357
|
+
|
|
358
|
+
server.tool(
|
|
359
|
+
"upload_media",
|
|
360
|
+
"Upload media (image/video) for use in tweets. Returns a mediaId to pass to post_tweet or reply_to_tweet.",
|
|
361
|
+
{
|
|
362
|
+
filePath: z.string().describe("Absolute path to the media file"),
|
|
363
|
+
alt: z.string().optional().describe("Alt text for accessibility"),
|
|
364
|
+
},
|
|
365
|
+
async ({ filePath, alt }) => {
|
|
366
|
+
const file = Bun.file(filePath);
|
|
367
|
+
const exists = await file.exists();
|
|
368
|
+
if (!exists) {
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text" as const, text: JSON.stringify({ success: false, error: `File not found: ${filePath}` }) }],
|
|
371
|
+
isError: true,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
375
|
+
const mimeType = file.type || "application/octet-stream";
|
|
376
|
+
const result = await executor.uploadMedia({ data, mimeType, alt });
|
|
377
|
+
return toMcpResult(result);
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Convert XClient result to MCP tool result format */
|
|
383
|
+
function toMcpResult(result: Record<string, unknown>) {
|
|
384
|
+
const isError = result.success === false;
|
|
385
|
+
return {
|
|
386
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
387
|
+
isError,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4
|
+
import { logger } from "hono/logger";
|
|
5
|
+
|
|
6
|
+
import { errorHandler } from "./middleware/error-handler.ts";
|
|
7
|
+
import { accountPoolMiddleware, type XbirdEnv } from "./middleware/account-pool.ts";
|
|
8
|
+
import { payerExtractMiddleware } from "./middleware/payer-extract.ts";
|
|
9
|
+
import { createPaymentMiddleware } from "./middleware/x402.ts";
|
|
10
|
+
import { NETWORK, ROUTE_PRICES } from "./config/pricing.ts";
|
|
11
|
+
import type { AccountsDB } from "./storage/accounts-db.ts";
|
|
12
|
+
|
|
13
|
+
import { health } from "./routes/health.ts";
|
|
14
|
+
import { tweets } from "./routes/tweets.ts";
|
|
15
|
+
import { users } from "./routes/users.ts";
|
|
16
|
+
import { search } from "./routes/search.ts";
|
|
17
|
+
import { timeline } from "./routes/timeline.ts";
|
|
18
|
+
import { engagement } from "./routes/engagement.ts";
|
|
19
|
+
import { follow } from "./routes/follow.ts";
|
|
20
|
+
import { lists } from "./routes/lists.ts";
|
|
21
|
+
import { news } from "./routes/news.ts";
|
|
22
|
+
import { media } from "./routes/media.ts";
|
|
23
|
+
import { mentions } from "./routes/mentions.ts";
|
|
24
|
+
import { bookmarks } from "./routes/bookmarks.ts";
|
|
25
|
+
import { createAccountsRoutes } from "./routes/accounts.ts";
|
|
26
|
+
import { authorize } from "./routes/authorize.ts";
|
|
27
|
+
|
|
28
|
+
export function createApp(payTo: string, accountsDb: AccountsDB): Hono<XbirdEnv> {
|
|
29
|
+
const app = new Hono<XbirdEnv>();
|
|
30
|
+
|
|
31
|
+
// Global middleware
|
|
32
|
+
app.use("*", cors());
|
|
33
|
+
app.use("*", logger());
|
|
34
|
+
app.use("*", errorHandler);
|
|
35
|
+
|
|
36
|
+
// Free routes (no payment, no auth)
|
|
37
|
+
app.route("/", health);
|
|
38
|
+
|
|
39
|
+
// Serve agent card (free)
|
|
40
|
+
app.get("/.well-known/agent.json", async (c) => {
|
|
41
|
+
const card = await Bun.file(
|
|
42
|
+
new URL("./erc8004/agent-card.json", import.meta.url).pathname
|
|
43
|
+
).json();
|
|
44
|
+
// Inject dynamic values
|
|
45
|
+
card.paymentAddress = payTo;
|
|
46
|
+
card.network = NETWORK;
|
|
47
|
+
return c.json(card);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// x402scan discovery document (free)
|
|
51
|
+
app.get("/.well-known/x402", (c) => {
|
|
52
|
+
const baseUrl = process.env.XBIRD_BASE_URL ?? `https://${c.req.header("host")}`;
|
|
53
|
+
const resources = Object.keys(ROUTE_PRICES).map((route) => {
|
|
54
|
+
// "GET /api/tweets/[id]" → "https://host/api/tweets/[id]"
|
|
55
|
+
const path = route.split(" ")[1];
|
|
56
|
+
return `${baseUrl}${path}`;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return c.json({
|
|
60
|
+
version: 1,
|
|
61
|
+
resources,
|
|
62
|
+
instructions: [
|
|
63
|
+
"# xbird — BYOA Twitter/X API",
|
|
64
|
+
"",
|
|
65
|
+
"Pay-per-request Twitter API. Bring your own Twitter account, pay with USDC on Base.",
|
|
66
|
+
"",
|
|
67
|
+
"## Quick Start",
|
|
68
|
+
"1. Register your Twitter account: `POST /api/accounts` with `{ authToken, ct0 }`",
|
|
69
|
+
"2. Use any endpoint — your account is used automatically",
|
|
70
|
+
"",
|
|
71
|
+
"## Alternative",
|
|
72
|
+
"Pass `X-Twitter-Auth-Token` and `X-Twitter-CT0` headers per-request.",
|
|
73
|
+
"",
|
|
74
|
+
"## Docs",
|
|
75
|
+
`Agent card: ${baseUrl}/.well-known/agent.json`,
|
|
76
|
+
].join("\n"),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Body size limit for API routes (10KB — prevents DoS via oversized payloads)
|
|
81
|
+
app.use("/api/*", bodyLimit({ maxSize: 10 * 1024 }));
|
|
82
|
+
|
|
83
|
+
// x402 payment middleware — only for /api/* routes
|
|
84
|
+
app.use("/api/*", createPaymentMiddleware(payTo));
|
|
85
|
+
|
|
86
|
+
// Extract payer wallet from verified payment-signature header
|
|
87
|
+
app.use("/api/*", payerExtractMiddleware());
|
|
88
|
+
|
|
89
|
+
// BYOA account resolve — headers → registered account → reject
|
|
90
|
+
// (account routes are skipped inside the middleware)
|
|
91
|
+
app.use("/api/*", accountPoolMiddleware(accountsDb));
|
|
92
|
+
|
|
93
|
+
// Authorize routes — payment-only, no Twitter credentials needed (for MCP local execution)
|
|
94
|
+
app.route("/api/authorize", authorize);
|
|
95
|
+
|
|
96
|
+
// Account management routes (BYOA registration)
|
|
97
|
+
app.route("/api/accounts", createAccountsRoutes(accountsDb));
|
|
98
|
+
|
|
99
|
+
// API routes
|
|
100
|
+
app.route("/api/tweets", tweets);
|
|
101
|
+
app.route("/api/users", users);
|
|
102
|
+
app.route("/api/search", search);
|
|
103
|
+
app.route("/api/timeline", timeline);
|
|
104
|
+
app.route("/api/mentions", mentions);
|
|
105
|
+
app.route("/api/bookmarks", bookmarks);
|
|
106
|
+
app.route("/api/lists", lists);
|
|
107
|
+
app.route("/api/news", news);
|
|
108
|
+
app.route("/api/media", media);
|
|
109
|
+
|
|
110
|
+
// Engagement routes share /api/tweets/:id/* namespace
|
|
111
|
+
app.route("/api/tweets", engagement);
|
|
112
|
+
|
|
113
|
+
// Follow routes share /api/users/:handle/* namespace
|
|
114
|
+
app.route("/api/users", follow);
|
|
115
|
+
|
|
116
|
+
return app;
|
|
117
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { XClient } from "../../lib/client-full.ts";
|
|
2
|
+
import { resolveCredentials } from "../../lib/auth.ts";
|
|
3
|
+
import type { Credentials } from "../../lib/types.ts";
|
|
4
|
+
|
|
5
|
+
interface AccountEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
authToken: string;
|
|
8
|
+
ct0: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PoolEntry {
|
|
12
|
+
name: string;
|
|
13
|
+
client: XClient;
|
|
14
|
+
lastUsed: number;
|
|
15
|
+
cooldownUntil: number;
|
|
16
|
+
disabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const COOLDOWN_MS = 2_000;
|
|
20
|
+
const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
export class AccountPool {
|
|
23
|
+
private accounts: PoolEntry[] = [];
|
|
24
|
+
private index = 0;
|
|
25
|
+
|
|
26
|
+
constructor(entries: AccountEntry[]) {
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const credentials: Credentials = {
|
|
29
|
+
authToken: entry.authToken,
|
|
30
|
+
ct0: entry.ct0,
|
|
31
|
+
source: "env",
|
|
32
|
+
};
|
|
33
|
+
this.accounts.push({
|
|
34
|
+
name: entry.name,
|
|
35
|
+
client: new XClient(credentials),
|
|
36
|
+
lastUsed: 0,
|
|
37
|
+
cooldownUntil: 0,
|
|
38
|
+
disabled: false,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get size(): number {
|
|
44
|
+
return this.accounts.length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get availableCount(): number {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
return this.accounts.filter(
|
|
50
|
+
(a) => !a.disabled && a.cooldownUntil <= now
|
|
51
|
+
).length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Acquire a client using round-robin with cooldown awareness */
|
|
55
|
+
acquire(): { client: XClient; name: string } | null {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const len = this.accounts.length;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < len; i++) {
|
|
60
|
+
const idx = (this.index + i) % len;
|
|
61
|
+
const entry = this.accounts[idx]!;
|
|
62
|
+
|
|
63
|
+
if (entry.disabled || entry.cooldownUntil > now) continue;
|
|
64
|
+
|
|
65
|
+
entry.lastUsed = now;
|
|
66
|
+
entry.cooldownUntil = now + COOLDOWN_MS;
|
|
67
|
+
this.index = (idx + 1) % len;
|
|
68
|
+
return { client: entry.client, name: entry.name };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Mark an account as rate-limited */
|
|
75
|
+
markRateLimited(name: string): void {
|
|
76
|
+
const entry = this.accounts.find((a) => a.name === name);
|
|
77
|
+
if (entry) {
|
|
78
|
+
entry.cooldownUntil = Date.now() + RATE_LIMIT_COOLDOWN_MS;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Disable an account entirely */
|
|
83
|
+
disable(name: string): void {
|
|
84
|
+
const entry = this.accounts.find((a) => a.name === name);
|
|
85
|
+
if (entry) {
|
|
86
|
+
entry.disabled = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Re-enable a disabled account */
|
|
91
|
+
enable(name: string): void {
|
|
92
|
+
const entry = this.accounts.find((a) => a.name === name);
|
|
93
|
+
if (entry) {
|
|
94
|
+
entry.disabled = false;
|
|
95
|
+
entry.cooldownUntil = 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Load accounts from env or config file */
|
|
100
|
+
static async load(): Promise<AccountPool> {
|
|
101
|
+
// 1. Try XBIRD_ACCOUNTS env (JSON array)
|
|
102
|
+
const envAccounts = process.env.XBIRD_ACCOUNTS;
|
|
103
|
+
if (envAccounts) {
|
|
104
|
+
const entries = JSON.parse(envAccounts) as AccountEntry[];
|
|
105
|
+
return new AccountPool(entries);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Try config file
|
|
109
|
+
const configPath = `${process.env.HOME}/.config/xbird/accounts.json`;
|
|
110
|
+
const file = Bun.file(configPath);
|
|
111
|
+
if (await file.exists()) {
|
|
112
|
+
const entries = (await file.json()) as AccountEntry[];
|
|
113
|
+
return new AccountPool(entries);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 3. Try single account from env
|
|
117
|
+
const authToken = process.env.TWITTER_AUTH_TOKEN ?? process.env.AUTH_TOKEN;
|
|
118
|
+
const ct0 = process.env.TWITTER_CT0 ?? process.env.CT0;
|
|
119
|
+
if (authToken && ct0) {
|
|
120
|
+
return new AccountPool([{ name: "default", authToken, ct0 }]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Try browser cookies (same as CLI)
|
|
124
|
+
const { credentials, warnings } = await resolveCredentials();
|
|
125
|
+
for (const w of warnings) console.warn(`[accounts] ${w}`);
|
|
126
|
+
if (credentials) {
|
|
127
|
+
console.log(`[accounts] Using browser cookies (source: ${credentials.source}${credentials.browser ? `, ${credentials.browser}` : ""})`);
|
|
128
|
+
return new AccountPool([{
|
|
129
|
+
name: `browser-${credentials.browser ?? credentials.source}`,
|
|
130
|
+
authToken: credentials.authToken,
|
|
131
|
+
ct0: credentials.ct0,
|
|
132
|
+
}]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return new AccountPool([]);
|
|
136
|
+
}
|
|
137
|
+
}
|