birdclaw 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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,492 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type {
4
+ TransportStatus,
5
+ XurlMentionsResponse,
6
+ XurlUserTweet,
7
+ } from "./types";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const TRANSPORT_STATUS_TTL_MS = 5 * 60_000;
11
+ const AUTHENTICATED_USER_TTL_MS = 60_000;
12
+ const JSON_RETRY_LIMIT = 6;
13
+
14
+ let transportStatusCache:
15
+ | {
16
+ expiresAt: number;
17
+ pending?: Promise<TransportStatus>;
18
+ value?: TransportStatus;
19
+ }
20
+ | undefined;
21
+ let authenticatedUserCache:
22
+ | {
23
+ expiresAt: number;
24
+ pending?: Promise<Record<string, unknown> | null>;
25
+ value?: Record<string, unknown> | null;
26
+ }
27
+ | undefined;
28
+
29
+ function liveWritesDisabled() {
30
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
31
+ }
32
+
33
+ function getJsonRetryBaseDelayMs() {
34
+ const value = Number(process.env.BIRDCLAW_XURL_RETRY_BASE_MS ?? "2000");
35
+ return Number.isFinite(value) && value >= 0 ? value : 2000;
36
+ }
37
+
38
+ function stripAnsi(value: string) {
39
+ // ANSI escape parsing needs a constructor to avoid literal control characters.
40
+ return value.replace(new RegExp("\\u001b\\[[0-9;]*m", "g"), "");
41
+ }
42
+
43
+ function formatExecError(error: unknown, fallback: string) {
44
+ if (!(error instanceof Error)) {
45
+ return fallback;
46
+ }
47
+
48
+ const parts = [error.message];
49
+ if (
50
+ "stdout" in error &&
51
+ typeof error.stdout === "string" &&
52
+ error.stdout.trim().length > 0
53
+ ) {
54
+ parts.push(stripAnsi(error.stdout).trim());
55
+ }
56
+ if (
57
+ "stderr" in error &&
58
+ typeof error.stderr === "string" &&
59
+ error.stderr.trim().length > 0
60
+ ) {
61
+ parts.push(stripAnsi(error.stderr).trim());
62
+ }
63
+
64
+ return parts.join("\n");
65
+ }
66
+
67
+ function parseErrorPayload(error: unknown) {
68
+ const stdout =
69
+ typeof error === "object" &&
70
+ error !== null &&
71
+ "stdout" in error &&
72
+ typeof error.stdout === "string"
73
+ ? stripAnsi(error.stdout)
74
+ : "";
75
+
76
+ const start = stdout.indexOf("{");
77
+ const end = stdout.lastIndexOf("}");
78
+ if (start < 0 || end <= start) {
79
+ return null;
80
+ }
81
+
82
+ try {
83
+ return JSON.parse(stdout.slice(start, end + 1)) as Record<string, unknown>;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function getRetryDelayMs(error: unknown, attempt: number) {
90
+ const payload = parseErrorPayload(error);
91
+ const status = Number(payload?.status ?? 0);
92
+ if (status !== 429) {
93
+ return null;
94
+ }
95
+
96
+ const baseDelay = getJsonRetryBaseDelayMs();
97
+ return Math.min(baseDelay * 2 ** attempt, 30_000);
98
+ }
99
+
100
+ async function sleep(ms: number) {
101
+ if (ms <= 0) {
102
+ return;
103
+ }
104
+ await new Promise((resolve) => setTimeout(resolve, ms));
105
+ }
106
+
107
+ export function resetTransportStatusCache() {
108
+ transportStatusCache = undefined;
109
+ }
110
+
111
+ export function resetAuthenticatedUserCache() {
112
+ authenticatedUserCache = undefined;
113
+ }
114
+
115
+ async function hasXurl(): Promise<boolean> {
116
+ try {
117
+ await execFileAsync("xurl", ["version"]);
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ export async function getTransportStatus(): Promise<TransportStatus> {
125
+ const now = Date.now();
126
+ if (transportStatusCache?.value && transportStatusCache.expiresAt > now) {
127
+ return transportStatusCache.value;
128
+ }
129
+
130
+ if (transportStatusCache?.pending) {
131
+ return transportStatusCache.pending;
132
+ }
133
+
134
+ const pending = (async () => {
135
+ const installed = await hasXurl();
136
+ if (!installed) {
137
+ return {
138
+ installed: false,
139
+ availableTransport: "local",
140
+ statusText: "xurl not installed. local mode active.",
141
+ };
142
+ }
143
+
144
+ try {
145
+ const { stdout } = await execFileAsync("xurl", ["auth", "status"]);
146
+ return {
147
+ installed: true,
148
+ availableTransport: "xurl",
149
+ statusText: "xurl available",
150
+ rawStatus: stdout.trim(),
151
+ };
152
+ } catch (error) {
153
+ return {
154
+ installed: true,
155
+ availableTransport: "local",
156
+ statusText: `xurl detected but auth unavailable: ${
157
+ error instanceof Error ? error.message : "unknown error"
158
+ }`,
159
+ };
160
+ }
161
+ })();
162
+
163
+ transportStatusCache = {
164
+ expiresAt: 0,
165
+ pending,
166
+ };
167
+
168
+ try {
169
+ const status = await pending;
170
+ transportStatusCache = {
171
+ expiresAt: Date.now() + TRANSPORT_STATUS_TTL_MS,
172
+ value: status,
173
+ };
174
+ return status;
175
+ } catch (error) {
176
+ transportStatusCache = undefined;
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ async function runShortcut(
182
+ args: string[],
183
+ ): Promise<{ ok: boolean; output: string }> {
184
+ if (liveWritesDisabled()) {
185
+ return { ok: true, output: "live writes disabled" };
186
+ }
187
+
188
+ try {
189
+ const { stdout, stderr } = await execFileAsync("xurl", args);
190
+ return { ok: true, output: stdout || stderr };
191
+ } catch (error) {
192
+ return {
193
+ ok: false,
194
+ output: formatExecError(error, "xurl execution failed"),
195
+ };
196
+ }
197
+ }
198
+
199
+ async function runJsonCommand(args: string[], attempt = 0) {
200
+ try {
201
+ const { stdout } = await execFileAsync("xurl", args);
202
+ return JSON.parse(stdout) as Record<string, unknown>;
203
+ } catch (error) {
204
+ const retryDelayMs = getRetryDelayMs(error, attempt);
205
+ if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
206
+ throw error;
207
+ }
208
+
209
+ await sleep(retryDelayMs);
210
+ return runJsonCommand(args, attempt + 1);
211
+ }
212
+ }
213
+
214
+ async function runMutationCommand(args: string[]) {
215
+ if (liveWritesDisabled()) {
216
+ return { ok: true, output: "live writes disabled" };
217
+ }
218
+
219
+ try {
220
+ const { stdout, stderr } = await execFileAsync("xurl", args);
221
+ return {
222
+ ok: true,
223
+ output: stdout || stderr || "ok",
224
+ };
225
+ } catch (error) {
226
+ return {
227
+ ok: false,
228
+ output: formatExecError(error, "xurl execution failed"),
229
+ };
230
+ }
231
+ }
232
+
233
+ export async function lookupUsersByIds(ids: string[]) {
234
+ if (ids.length === 0) {
235
+ return [];
236
+ }
237
+
238
+ const query = new URLSearchParams({
239
+ ids: ids.join(","),
240
+ "user.fields":
241
+ "description,public_metrics,profile_image_url,created_at,verified",
242
+ });
243
+ const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
244
+ const data = payload.data;
245
+ return Array.isArray(data) ? (data as Array<Record<string, unknown>>) : [];
246
+ }
247
+
248
+ export async function lookupUsersByHandles(handles: string[]) {
249
+ if (handles.length === 0) {
250
+ return [];
251
+ }
252
+
253
+ const query = new URLSearchParams({
254
+ usernames: handles.map((item) => item.replace(/^@/, "")).join(","),
255
+ "user.fields":
256
+ "description,public_metrics,profile_image_url,created_at,verified",
257
+ });
258
+ const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
259
+ const data = payload.data;
260
+ return Array.isArray(data) ? (data as Array<Record<string, unknown>>) : [];
261
+ }
262
+
263
+ export async function lookupAuthenticatedUser() {
264
+ const now = Date.now();
265
+ if (
266
+ authenticatedUserCache &&
267
+ "value" in authenticatedUserCache &&
268
+ authenticatedUserCache.expiresAt > now
269
+ ) {
270
+ return authenticatedUserCache.value ?? null;
271
+ }
272
+
273
+ if (authenticatedUserCache?.pending) {
274
+ return authenticatedUserCache.pending;
275
+ }
276
+
277
+ const pending = (async () => {
278
+ const payload = await runJsonCommand(["whoami"]);
279
+ const data = payload.data;
280
+ return data && typeof data === "object"
281
+ ? (data as Record<string, unknown>)
282
+ : null;
283
+ })();
284
+
285
+ authenticatedUserCache = {
286
+ expiresAt: 0,
287
+ pending,
288
+ };
289
+
290
+ try {
291
+ const value = await pending;
292
+ authenticatedUserCache = {
293
+ expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
294
+ value,
295
+ };
296
+ return value;
297
+ } catch (error) {
298
+ authenticatedUserCache = undefined;
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ export async function listMentionsViaXurl({
304
+ maxResults,
305
+ username,
306
+ userId,
307
+ paginationToken,
308
+ }: {
309
+ maxResults: number;
310
+ username?: string;
311
+ userId?: string;
312
+ paginationToken?: string;
313
+ }): Promise<XurlMentionsResponse> {
314
+ let resolvedUserId = userId;
315
+ if (!resolvedUserId) {
316
+ if (username) {
317
+ const [user] = await lookupUsersByHandles([username]);
318
+ if (!user?.id) {
319
+ throw new Error(`Could not resolve X user id for @${username}`);
320
+ }
321
+ resolvedUserId = String(user.id);
322
+ } else {
323
+ const user = await lookupAuthenticatedUser();
324
+ if (!user?.id) {
325
+ throw new Error("Could not resolve authenticated X user id");
326
+ }
327
+ resolvedUserId = String(user.id);
328
+ }
329
+ }
330
+
331
+ const query = new URLSearchParams({
332
+ max_results: String(maxResults),
333
+ expansions: "author_id",
334
+ "tweet.fields": "created_at,conversation_id,entities,public_metrics",
335
+ "user.fields":
336
+ "description,public_metrics,profile_image_url,created_at,verified",
337
+ });
338
+ if (paginationToken) {
339
+ query.set("pagination_token", paginationToken);
340
+ }
341
+
342
+ const payload = await runJsonCommand([
343
+ `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
344
+ ]);
345
+ return {
346
+ data: Array.isArray(payload.data)
347
+ ? (payload.data as XurlMentionsResponse["data"])
348
+ : [],
349
+ includes:
350
+ payload.includes && typeof payload.includes === "object"
351
+ ? (payload.includes as XurlMentionsResponse["includes"])
352
+ : undefined,
353
+ meta:
354
+ payload.meta && typeof payload.meta === "object"
355
+ ? (payload.meta as XurlMentionsResponse["meta"])
356
+ : undefined,
357
+ };
358
+ }
359
+
360
+ export async function listBlockedUsers(
361
+ userId: string,
362
+ paginationToken?: string,
363
+ ) {
364
+ const query = new URLSearchParams({
365
+ max_results: "100",
366
+ "user.fields": "description,public_metrics,profile_image_url,created_at",
367
+ });
368
+ if (paginationToken) {
369
+ query.set("pagination_token", paginationToken);
370
+ }
371
+
372
+ const payload = await runJsonCommand([
373
+ `/2/users/${userId}/blocking?${query}`,
374
+ ]);
375
+ const data = Array.isArray(payload.data)
376
+ ? (payload.data as Array<Record<string, unknown>>)
377
+ : [];
378
+ const meta =
379
+ payload.meta && typeof payload.meta === "object"
380
+ ? (payload.meta as Record<string, unknown>)
381
+ : null;
382
+
383
+ return {
384
+ items: data,
385
+ nextToken:
386
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
387
+ };
388
+ }
389
+
390
+ export async function listUserTweets(
391
+ userId: string,
392
+ {
393
+ maxResults,
394
+ paginationToken,
395
+ excludeRetweets = true,
396
+ }: {
397
+ maxResults: number;
398
+ paginationToken?: string;
399
+ excludeRetweets?: boolean;
400
+ },
401
+ ) {
402
+ const query = new URLSearchParams({
403
+ max_results: String(maxResults),
404
+ "tweet.fields":
405
+ "created_at,conversation_id,public_metrics,referenced_tweets",
406
+ });
407
+ if (excludeRetweets) {
408
+ query.set("exclude", "retweets");
409
+ }
410
+ if (paginationToken) {
411
+ query.set("pagination_token", paginationToken);
412
+ }
413
+
414
+ const payload = await runJsonCommand([`/2/users/${userId}/tweets?${query}`]);
415
+ const data = Array.isArray(payload.data)
416
+ ? (payload.data as XurlUserTweet[])
417
+ : [];
418
+ const meta =
419
+ payload.meta && typeof payload.meta === "object"
420
+ ? (payload.meta as Record<string, unknown>)
421
+ : null;
422
+
423
+ return {
424
+ items: data,
425
+ nextToken:
426
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
427
+ };
428
+ }
429
+
430
+ export async function postViaXurl(text: string) {
431
+ return runShortcut(["post", text]);
432
+ }
433
+
434
+ export async function replyViaXurl(tweetId: string, text: string) {
435
+ return runShortcut(["reply", tweetId, text]);
436
+ }
437
+
438
+ export async function dmViaXurl(handle: string, text: string) {
439
+ return runShortcut([
440
+ "dm",
441
+ handle.startsWith("@") ? handle : `@${handle}`,
442
+ text,
443
+ ]);
444
+ }
445
+
446
+ export async function blockUserViaXurl(
447
+ sourceUserId: string,
448
+ targetUserId: string,
449
+ ) {
450
+ return runMutationCommand([
451
+ "-X",
452
+ "POST",
453
+ `/2/users/${sourceUserId}/blocking`,
454
+ "-d",
455
+ JSON.stringify({ target_user_id: targetUserId }),
456
+ ]);
457
+ }
458
+
459
+ export async function unblockUserViaXurl(
460
+ sourceUserId: string,
461
+ targetUserId: string,
462
+ ) {
463
+ return runMutationCommand([
464
+ "-X",
465
+ "DELETE",
466
+ `/2/users/${sourceUserId}/blocking/${targetUserId}`,
467
+ ]);
468
+ }
469
+
470
+ export async function muteUserViaXurl(
471
+ sourceUserId: string,
472
+ targetUserId: string,
473
+ ) {
474
+ return runMutationCommand([
475
+ "-X",
476
+ "POST",
477
+ `/2/users/${sourceUserId}/muting`,
478
+ "-d",
479
+ JSON.stringify({ target_user_id: targetUserId }),
480
+ ]);
481
+ }
482
+
483
+ export async function unmuteUserViaXurl(
484
+ sourceUserId: string,
485
+ targetUserId: string,
486
+ ) {
487
+ return runMutationCommand([
488
+ "-X",
489
+ "DELETE",
490
+ `/2/users/${sourceUserId}/muting/${targetUserId}`,
491
+ ]);
492
+ }