birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
package/src/lib/bird.ts CHANGED
@@ -3,7 +3,9 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { Effect } from "effect";
6
7
  import { getBirdCommand } from "./config";
8
+ import { runEffectPromise } from "./effect-runtime";
7
9
  import type {
8
10
  XurlMentionData,
9
11
  XurlFollowUsersResponse,
@@ -15,6 +17,7 @@ import type {
15
17
 
16
18
  const execFileAsync = promisify(execFile);
17
19
  const BIRD_JSON_MAX_BUFFER_BYTES = 512 * 1024 * 1024;
20
+ const BIRD_STDOUT_REDIRECT_SCRIPT = 'out="$1"; shift; exec "$@" > "$out"';
18
21
 
19
22
  interface BirdTweetMedia {
20
23
  type?: string;
@@ -36,7 +39,9 @@ interface BirdTweetItem {
36
39
  conversationId?: string;
37
40
  inReplyToStatusId?: string | null;
38
41
  quotedStatusId?: string | null;
42
+ retweetedStatusId?: string | null;
39
43
  quotedTweet?: { id?: string | null } | null;
44
+ retweetedTweet?: { id?: string | null } | null;
40
45
  author?: BirdTweetAuthor;
41
46
  authorId?: string;
42
47
  media?: BirdTweetMedia[];
@@ -58,6 +63,8 @@ export interface BirdDmEvent {
58
63
  recipientId?: string;
59
64
  sender?: BirdDmUser;
60
65
  recipient?: BirdDmUser;
66
+ inboxKind?: "accepted" | "request";
67
+ isMessageRequest?: boolean;
61
68
  }
62
69
 
63
70
  export interface BirdDmConversation {
@@ -66,6 +73,8 @@ export interface BirdDmConversation {
66
73
  messages: BirdDmEvent[];
67
74
  lastMessageAt?: string;
68
75
  lastMessagePreview?: string;
76
+ inboxKind?: "accepted" | "request";
77
+ isMessageRequest?: boolean;
69
78
  }
70
79
 
71
80
  export interface BirdDmsResponse {
@@ -74,6 +83,27 @@ export interface BirdDmsResponse {
74
83
  events: BirdDmEvent[];
75
84
  }
76
85
 
86
+ export interface BirdAuthenticatedAccount {
87
+ id?: string;
88
+ username: string;
89
+ }
90
+
91
+ export type BirdDmRequestAction = "accept" | "reject" | "block";
92
+
93
+ export type BirdDmMutationResponse =
94
+ | {
95
+ success: true;
96
+ conversationId?: string;
97
+ userId?: string;
98
+ username?: string;
99
+ blockedUserId?: string;
100
+ blockedUsername?: string;
101
+ }
102
+ | {
103
+ success: false;
104
+ error: string;
105
+ };
106
+
77
107
  interface BirdUserOverviewPayload {
78
108
  user?: {
79
109
  id?: string;
@@ -223,23 +253,90 @@ function isUnsupportedBirdOptionError(error: unknown, option: string) {
223
253
  return text.includes(option) && /unknown option|error:/i.test(text);
224
254
  }
225
255
 
226
- async function runBirdJsonCommand(args: string[], timeoutMs?: number) {
227
- const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
228
- const stdoutPath = join(tempDir, "stdout.json");
229
- const birdCommand = getBirdCommand();
230
- try {
231
- const shellScript = 'out="$1"; shift; exec "$@" > "$out"';
232
- await execFileAsync(
233
- "/bin/bash",
234
- ["-c", shellScript, "birdclaw-bird", stdoutPath, birdCommand, ...args],
235
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
236
- );
237
- return readFileSync(stdoutPath, "utf8");
238
- } catch (error) {
239
- throw formatBirdCommandError(error, birdCommand);
240
- } finally {
241
- rmSync(tempDir, { recursive: true, force: true });
242
- }
256
+ function makeBirdStdoutTempEffect() {
257
+ return Effect.acquireRelease(
258
+ Effect.sync(() => {
259
+ const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
260
+ return { tempDir, stdoutPath: join(tempDir, "stdout.json") };
261
+ }),
262
+ ({ tempDir }) =>
263
+ Effect.sync(() => rmSync(tempDir, { recursive: true, force: true })),
264
+ );
265
+ }
266
+
267
+ export function runBirdJsonCommandEffect(args: string[], timeoutMs?: number) {
268
+ return Effect.scoped(
269
+ Effect.gen(function* () {
270
+ const birdCommand = yield* Effect.try({
271
+ try: () => getBirdCommand(),
272
+ catch: (error) =>
273
+ error instanceof Error ? error : new Error(String(error)),
274
+ });
275
+ const { stdoutPath } = yield* makeBirdStdoutTempEffect();
276
+ yield* Effect.tryPromise({
277
+ try: () =>
278
+ execFileAsync(
279
+ "/bin/bash",
280
+ [
281
+ "-c",
282
+ BIRD_STDOUT_REDIRECT_SCRIPT,
283
+ "birdclaw-bird",
284
+ stdoutPath,
285
+ birdCommand,
286
+ ...args,
287
+ ],
288
+ { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
289
+ ),
290
+ catch: (error) => formatBirdCommandError(error, birdCommand),
291
+ });
292
+ return yield* Effect.try({
293
+ try: () => readFileSync(stdoutPath, "utf8"),
294
+ catch: (error) => error,
295
+ });
296
+ }),
297
+ );
298
+ }
299
+
300
+ function runBirdJsonCommandAllowFailureEffect(
301
+ args: string[],
302
+ timeoutMs?: number,
303
+ ) {
304
+ return Effect.scoped(
305
+ Effect.gen(function* () {
306
+ const birdCommand = yield* Effect.try({
307
+ try: () => getBirdCommand(),
308
+ catch: (error) =>
309
+ error instanceof Error ? error : new Error(String(error)),
310
+ });
311
+ const { stdoutPath } = yield* makeBirdStdoutTempEffect();
312
+ yield* Effect.tryPromise({
313
+ try: () =>
314
+ execFileAsync(
315
+ "/bin/bash",
316
+ [
317
+ "-c",
318
+ BIRD_STDOUT_REDIRECT_SCRIPT,
319
+ "birdclaw-bird",
320
+ stdoutPath,
321
+ birdCommand,
322
+ ...args,
323
+ ],
324
+ { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
325
+ ).catch((error: unknown) => {
326
+ const stdout = readFileSync(stdoutPath, "utf8");
327
+ if (stdout.trim().length > 0) {
328
+ return { stdout: "", stderr: "" };
329
+ }
330
+ throw formatBirdCommandError(error, birdCommand);
331
+ }),
332
+ catch: (error) => error,
333
+ });
334
+ return yield* Effect.try({
335
+ try: () => readFileSync(stdoutPath, "utf8"),
336
+ catch: (error) => error,
337
+ });
338
+ }),
339
+ );
243
340
  }
244
341
 
245
342
  function getBirdTweetItems(payload: unknown, command: string) {
@@ -304,6 +401,16 @@ function toReferencedTweets(item: BirdTweetItem) {
304
401
  references.push({ type: "quoted", id: quotedTweetId });
305
402
  }
306
403
 
404
+ const retweetedTweetId =
405
+ typeof item.retweetedStatusId === "string" && item.retweetedStatusId
406
+ ? item.retweetedStatusId
407
+ : typeof item.retweetedTweet?.id === "string" && item.retweetedTweet.id
408
+ ? item.retweetedTweet.id
409
+ : null;
410
+ if (retweetedTweetId) {
411
+ references.push({ type: "retweeted", id: retweetedTweetId });
412
+ }
413
+
307
414
  return references.length > 0 ? references : undefined;
308
415
  }
309
416
 
@@ -352,23 +459,51 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
352
459
  };
353
460
  }
354
461
 
355
- export async function listMentionsViaBird({
462
+ function parseBirdJsonEffect(stdout: string) {
463
+ return Effect.try({
464
+ try: () => parseBirdJson(stdout),
465
+ catch: (error) => error,
466
+ });
467
+ }
468
+
469
+ function normalizeBirdTweetsPayloadEffect(payload: unknown, command: string) {
470
+ return Effect.try({
471
+ try: () => normalizeBirdTweets(getBirdTweetItems(payload, command)),
472
+ catch: (error) => error,
473
+ });
474
+ }
475
+
476
+ function normalizeBirdTweetItemEffect(payload: unknown, command: string) {
477
+ return Effect.try({
478
+ try: () => getBirdTweetItem(payload, command),
479
+ catch: (error) => error,
480
+ });
481
+ }
482
+
483
+ export function listMentionsViaBirdEffect({
356
484
  maxResults,
357
485
  }: {
358
486
  maxResults: number;
359
- }): Promise<XurlMentionsResponse> {
360
- const stdout = await runBirdJsonCommand([
361
- "mentions",
362
- "-n",
363
- String(maxResults),
364
- "--json",
365
- ]);
366
- const payload = parseBirdJson(stdout);
487
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
488
+ return Effect.gen(function* () {
489
+ const stdout = yield* runBirdJsonCommandEffect([
490
+ "mentions",
491
+ "-n",
492
+ String(maxResults),
493
+ "--json",
494
+ ]);
495
+ const payload = yield* parseBirdJsonEffect(stdout);
496
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "mentions");
497
+ });
498
+ }
367
499
 
368
- return normalizeBirdTweets(getBirdTweetItems(payload, "mentions"));
500
+ export function listMentionsViaBird(options: {
501
+ maxResults: number;
502
+ }): Promise<XurlMentionsResponse> {
503
+ return runEffectPromise(listMentionsViaBirdEffect(options));
369
504
  }
370
505
 
371
- async function listTweetsViaBirdCommand({
506
+ function listTweetsViaBirdCommandEffect({
372
507
  command,
373
508
  maxResults,
374
509
  all,
@@ -378,86 +513,114 @@ async function listTweetsViaBirdCommand({
378
513
  maxResults: number;
379
514
  all?: boolean;
380
515
  maxPages?: number;
381
- }): Promise<XurlMentionsResponse> {
382
- const args = [command, "-n", String(maxResults), "--json"];
383
- if (all) {
384
- args.push("--all");
385
- }
386
- if (maxPages !== undefined) {
387
- args.push("--max-pages", String(maxPages));
388
- }
389
- const stdout = await runBirdJsonCommand(args);
390
- const payload = parseBirdJson(stdout);
391
-
392
- return normalizeBirdTweets(getBirdTweetItems(payload, command));
516
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
517
+ return Effect.gen(function* () {
518
+ const args = [command, "-n", String(maxResults), "--json"];
519
+ if (all) {
520
+ args.push("--all");
521
+ }
522
+ if (maxPages !== undefined) {
523
+ args.push("--max-pages", String(maxPages));
524
+ }
525
+ const stdout = yield* runBirdJsonCommandEffect(args);
526
+ const payload = yield* parseBirdJsonEffect(stdout);
527
+ return yield* normalizeBirdTweetsPayloadEffect(payload, command);
528
+ });
393
529
  }
394
530
 
395
- export async function listLikedTweetsViaBird({
396
- maxResults,
397
- all,
398
- maxPages,
399
- }: {
531
+ export function listLikedTweetsViaBirdEffect(options: {
400
532
  maxResults: number;
401
533
  all?: boolean;
402
534
  maxPages?: number;
403
- }): Promise<XurlMentionsResponse> {
404
- return listTweetsViaBirdCommand({
535
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
536
+ return listTweetsViaBirdCommandEffect({
405
537
  command: "likes",
406
- maxResults,
407
- all,
408
- maxPages,
538
+ ...options,
409
539
  });
410
540
  }
411
541
 
412
- export async function listBookmarkedTweetsViaBird({
413
- maxResults,
414
- all,
415
- maxPages,
416
- }: {
542
+ export function listLikedTweetsViaBird(options: {
417
543
  maxResults: number;
418
544
  all?: boolean;
419
545
  maxPages?: number;
420
546
  }): Promise<XurlMentionsResponse> {
421
- return listTweetsViaBirdCommand({
547
+ return runEffectPromise(listLikedTweetsViaBirdEffect(options));
548
+ }
549
+
550
+ export function listBookmarkedTweetsViaBirdEffect(options: {
551
+ maxResults: number;
552
+ all?: boolean;
553
+ maxPages?: number;
554
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
555
+ return listTweetsViaBirdCommandEffect({
422
556
  command: "bookmarks",
423
- maxResults,
424
- all,
425
- maxPages,
557
+ ...options,
426
558
  });
427
559
  }
428
560
 
429
- export async function lookupTweetsByIdsViaBird(
561
+ export function listBookmarkedTweetsViaBird(options: {
562
+ maxResults: number;
563
+ all?: boolean;
564
+ maxPages?: number;
565
+ }): Promise<XurlMentionsResponse> {
566
+ return runEffectPromise(listBookmarkedTweetsViaBirdEffect(options));
567
+ }
568
+
569
+ export function lookupTweetsByIdsViaBirdEffect(
430
570
  ids: string[],
431
- ): Promise<XurlTweetsResponse> {
571
+ ): Effect.Effect<XurlTweetsResponse, unknown> {
432
572
  if (ids.length === 0) {
433
- return { data: [] };
573
+ return Effect.succeed({ data: [] });
434
574
  }
435
575
 
436
- const tweets = await Promise.all(
437
- ids.map(async (id) => {
438
- const stdout = await runBirdJsonCommand(["read", id, "--json"]);
439
- return getBirdTweetItem(parseBirdJson(stdout), "read");
440
- }),
441
- );
576
+ return Effect.gen(function* () {
577
+ const tweets = yield* Effect.forEach(
578
+ ids,
579
+ (id) =>
580
+ Effect.gen(function* () {
581
+ const stdout = yield* runBirdJsonCommandEffect([
582
+ "read",
583
+ id,
584
+ "--json",
585
+ ]);
586
+ const payload = yield* parseBirdJsonEffect(stdout);
587
+ return yield* normalizeBirdTweetItemEffect(payload, "read");
588
+ }),
589
+ { concurrency: "unbounded" },
590
+ );
591
+ return normalizeBirdTweets(tweets);
592
+ });
593
+ }
442
594
 
443
- return normalizeBirdTweets(tweets);
595
+ export function lookupTweetsByIdsViaBird(
596
+ ids: string[],
597
+ ): Promise<XurlTweetsResponse> {
598
+ return runEffectPromise(lookupTweetsByIdsViaBirdEffect(ids));
444
599
  }
445
600
 
446
- export async function listHomeTimelineViaBird({
601
+ export function listHomeTimelineViaBirdEffect({
447
602
  maxResults,
448
603
  following = true,
449
604
  }: {
450
605
  maxResults: number;
451
606
  following?: boolean;
452
- }): Promise<XurlMentionsResponse> {
453
- const args = ["home", "-n", String(maxResults), "--json"];
454
- if (following) {
455
- args.push("--following");
456
- }
457
- const stdout = await runBirdJsonCommand(args);
458
- const payload = parseBirdJson(stdout);
607
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
608
+ return Effect.gen(function* () {
609
+ const args = ["home", "-n", String(maxResults), "--json"];
610
+ if (following) {
611
+ args.push("--following");
612
+ }
613
+ const stdout = yield* runBirdJsonCommandEffect(args);
614
+ const payload = yield* parseBirdJsonEffect(stdout);
615
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "home");
616
+ });
617
+ }
459
618
 
460
- return normalizeBirdTweets(getBirdTweetItems(payload, "home"));
619
+ export function listHomeTimelineViaBird(options: {
620
+ maxResults: number;
621
+ following?: boolean;
622
+ }): Promise<XurlMentionsResponse> {
623
+ return runEffectPromise(listHomeTimelineViaBirdEffect(options));
461
624
  }
462
625
 
463
626
  function normalizeBirdFollowUsers(
@@ -490,7 +653,18 @@ function normalizeBirdFollowUsers(
490
653
  };
491
654
  }
492
655
 
493
- export async function listFollowUsersViaBird({
656
+ function normalizeBirdFollowUsersEffect(
657
+ payload: unknown,
658
+ command: "followers" | "following",
659
+ maxResults: number,
660
+ ) {
661
+ return Effect.try({
662
+ try: () => normalizeBirdFollowUsers(payload, command, maxResults),
663
+ catch: (error) => error,
664
+ });
665
+ }
666
+
667
+ export function listFollowUsersViaBirdEffect({
494
668
  direction,
495
669
  userId,
496
670
  maxResults,
@@ -502,24 +676,39 @@ export async function listFollowUsersViaBird({
502
676
  maxResults: number;
503
677
  all?: boolean;
504
678
  maxPages?: number;
505
- }): Promise<XurlFollowUsersResponse> {
506
- const args = [direction, "-n", String(maxResults), "--json"];
507
- if (userId) {
508
- args.push("--user", userId);
509
- }
510
- if (all) {
511
- args.push("--all");
512
- }
513
- if (maxPages !== undefined) {
514
- args.push("--max-pages", String(maxPages));
515
- }
516
- const stdout = await runBirdJsonCommand(args);
517
- const payload = parseBirdJson(stdout);
679
+ }): Effect.Effect<XurlFollowUsersResponse, unknown> {
680
+ return Effect.gen(function* () {
681
+ const args = [direction, "-n", String(maxResults), "--json"];
682
+ if (userId) {
683
+ args.push("--user", userId);
684
+ }
685
+ if (all) {
686
+ args.push("--all");
687
+ }
688
+ if (maxPages !== undefined) {
689
+ args.push("--max-pages", String(maxPages));
690
+ }
691
+ const stdout = yield* runBirdJsonCommandEffect(args);
692
+ const payload = yield* parseBirdJsonEffect(stdout);
693
+ return yield* normalizeBirdFollowUsersEffect(
694
+ payload,
695
+ direction,
696
+ maxResults,
697
+ );
698
+ });
699
+ }
518
700
 
519
- return normalizeBirdFollowUsers(payload, direction, maxResults);
701
+ export function listFollowUsersViaBird(options: {
702
+ direction: "followers" | "following";
703
+ userId?: string;
704
+ maxResults: number;
705
+ all?: boolean;
706
+ maxPages?: number;
707
+ }): Promise<XurlFollowUsersResponse> {
708
+ return runEffectPromise(listFollowUsersViaBirdEffect(options));
520
709
  }
521
710
 
522
- export async function listThreadViaBird({
711
+ export function listThreadViaBirdEffect({
523
712
  tweetId,
524
713
  all,
525
714
  maxPages,
@@ -529,75 +718,224 @@ export async function listThreadViaBird({
529
718
  all?: boolean;
530
719
  maxPages?: number;
531
720
  timeoutMs?: number;
721
+ }): Effect.Effect<XurlMentionsResponse, unknown> {
722
+ return Effect.gen(function* () {
723
+ const args = ["thread", tweetId, "--json"];
724
+ if (all) {
725
+ args.push("--all");
726
+ }
727
+ if (maxPages !== undefined) {
728
+ args.push("--max-pages", String(maxPages));
729
+ }
730
+ const stdout = yield* runBirdJsonCommandEffect(args, timeoutMs);
731
+ const payload = yield* parseBirdJsonEffect(stdout);
732
+ return yield* normalizeBirdTweetsPayloadEffect(payload, "thread");
733
+ });
734
+ }
735
+
736
+ export function listThreadViaBird(options: {
737
+ tweetId: string;
738
+ all?: boolean;
739
+ maxPages?: number;
740
+ timeoutMs?: number;
532
741
  }): Promise<XurlMentionsResponse> {
533
- const args = ["thread", tweetId, "--json"];
534
- if (all) {
535
- args.push("--all");
536
- }
537
- if (maxPages !== undefined) {
538
- args.push("--max-pages", String(maxPages));
742
+ return runEffectPromise(listThreadViaBirdEffect(options));
743
+ }
744
+
745
+ function normalizeBirdDmsPayloadEffect(payload: unknown) {
746
+ return Effect.try({
747
+ try: () => {
748
+ if (
749
+ !payload ||
750
+ typeof payload !== "object" ||
751
+ (payload as { success?: unknown }).success !== true ||
752
+ !Array.isArray(
753
+ (payload as { conversations?: unknown }).conversations,
754
+ ) ||
755
+ !Array.isArray((payload as { events?: unknown }).events)
756
+ ) {
757
+ throw new Error("bird dms returned unexpected JSON");
758
+ }
759
+
760
+ return payload as BirdDmsResponse;
761
+ },
762
+ catch: (error) => error,
763
+ });
764
+ }
765
+
766
+ function parseBirdWhoami(stdout: string): BirdAuthenticatedAccount {
767
+ const usernameMatch = stdout.match(/@([A-Za-z0-9_]{1,15})\b/);
768
+ if (!usernameMatch?.[1]) {
769
+ throw new Error("bird whoami did not report an authenticated username");
539
770
  }
540
- const stdout = await runBirdJsonCommand(args, timeoutMs);
541
- const payload = parseBirdJson(stdout);
771
+ const id = stdout
772
+ .split(/\r?\n/)
773
+ .map((line) => line.trim())
774
+ .map((line) => {
775
+ const labeled = line.match(/(?:🪪|user_?id:?)[^\d]*(\d{2,})/i);
776
+ if (labeled?.[1]) {
777
+ return labeled[1];
778
+ }
779
+ if (/[A-Za-z@]/.test(line)) {
780
+ return undefined;
781
+ }
782
+ return line.match(/^\D*(\d{2,})\D*$/)?.[1];
783
+ })
784
+ .find((value): value is string => Boolean(value));
785
+ return {
786
+ username: usernameMatch[1],
787
+ ...(id ? { id } : {}),
788
+ };
789
+ }
542
790
 
543
- return normalizeBirdTweets(getBirdTweetItems(payload, "thread"));
791
+ export function getAuthenticatedBirdAccountEffect(): Effect.Effect<
792
+ BirdAuthenticatedAccount,
793
+ unknown
794
+ > {
795
+ return Effect.gen(function* () {
796
+ const stdout = yield* runBirdJsonCommandEffect(["whoami"]);
797
+ return yield* Effect.try({
798
+ try: () => parseBirdWhoami(stdout),
799
+ catch: (error) => error,
800
+ });
801
+ });
544
802
  }
545
803
 
546
- export async function listDirectMessagesViaBird({
804
+ export function getAuthenticatedBirdAccount(): Promise<BirdAuthenticatedAccount> {
805
+ return runEffectPromise(getAuthenticatedBirdAccountEffect());
806
+ }
807
+
808
+ export function listDirectMessagesViaBirdEffect({
547
809
  maxResults,
810
+ inbox = "all",
811
+ maxPages,
812
+ allPages = false,
813
+ pageDelayMs,
548
814
  }: {
549
815
  maxResults: number;
816
+ inbox?: "all" | "accepted" | "requests";
817
+ maxPages?: number;
818
+ allPages?: boolean;
819
+ pageDelayMs?: number;
820
+ }): Effect.Effect<BirdDmsResponse, unknown> {
821
+ return Effect.gen(function* () {
822
+ const args = ["dms", "-n", String(maxResults), "--json"];
823
+ if (inbox !== "all") {
824
+ args.push("--inbox", inbox);
825
+ }
826
+ if (allPages) {
827
+ args.push("--all-pages");
828
+ } else if (typeof maxPages === "number") {
829
+ args.push("--max-pages", String(maxPages));
830
+ }
831
+ if (typeof pageDelayMs === "number" && pageDelayMs > 0) {
832
+ args.push("--page-delay-ms", String(pageDelayMs));
833
+ }
834
+ const stdout = yield* runBirdJsonCommandEffect(args);
835
+ const payload = yield* parseBirdJsonEffect(stdout);
836
+ return yield* normalizeBirdDmsPayloadEffect(payload);
837
+ });
838
+ }
839
+
840
+ export function listDirectMessagesViaBird(options: {
841
+ maxResults: number;
842
+ inbox?: "all" | "accepted" | "requests";
843
+ maxPages?: number;
844
+ allPages?: boolean;
845
+ pageDelayMs?: number;
550
846
  }): Promise<BirdDmsResponse> {
551
- const stdout = await runBirdJsonCommand([
552
- "dms",
553
- "-n",
554
- String(maxResults),
555
- "--json",
556
- ]);
557
- const payload = parseBirdJson(stdout);
558
- if (
559
- !payload ||
560
- typeof payload !== "object" ||
561
- (payload as { success?: unknown }).success !== true ||
562
- !Array.isArray((payload as { conversations?: unknown }).conversations) ||
563
- !Array.isArray((payload as { events?: unknown }).events)
564
- ) {
565
- throw new Error("bird dms returned unexpected JSON");
566
- }
847
+ return runEffectPromise(listDirectMessagesViaBirdEffect(options));
848
+ }
567
849
 
568
- return payload as BirdDmsResponse;
850
+ export function runDirectMessageRequestMutationViaBirdEffect({
851
+ action,
852
+ conversationId,
853
+ maxPages,
854
+ allPages = false,
855
+ }: {
856
+ action: BirdDmRequestAction;
857
+ conversationId: string;
858
+ maxPages?: number;
859
+ allPages?: boolean;
860
+ }): Effect.Effect<BirdDmMutationResponse, unknown> {
861
+ return Effect.gen(function* () {
862
+ const command =
863
+ action === "accept"
864
+ ? "dm-accept"
865
+ : action === "reject"
866
+ ? "dm-reject"
867
+ : "dm-block";
868
+ const args = [command, conversationId, "--json"];
869
+ if (action === "block") {
870
+ if (allPages) {
871
+ args.push("--all-pages");
872
+ } else if (typeof maxPages === "number") {
873
+ args.push("--max-pages", String(maxPages));
874
+ }
875
+ }
876
+ const stdout = yield* runBirdJsonCommandAllowFailureEffect(args);
877
+ const payload = yield* parseBirdJsonEffect(stdout);
878
+ if (
879
+ payload &&
880
+ typeof payload === "object" &&
881
+ typeof (payload as { success?: unknown }).success === "boolean"
882
+ ) {
883
+ return payload as BirdDmMutationResponse;
884
+ }
885
+ throw new Error(`bird ${command} returned unexpected JSON`);
886
+ });
569
887
  }
570
888
 
571
- export async function lookupProfileViaBird(
889
+ export function runDirectMessageRequestMutationViaBird(options: {
890
+ action: BirdDmRequestAction;
891
+ conversationId: string;
892
+ maxPages?: number;
893
+ allPages?: boolean;
894
+ }): Promise<BirdDmMutationResponse> {
895
+ return runEffectPromise(
896
+ runDirectMessageRequestMutationViaBirdEffect(options),
897
+ );
898
+ }
899
+
900
+ export function lookupProfileViaBirdEffect(
572
901
  usernameOrId: string,
573
- ): Promise<XurlMentionUser | null> {
574
- const target = usernameOrId.trim().replace(/^@/, "");
575
- if (!target) {
576
- return null;
577
- }
902
+ ): Effect.Effect<XurlMentionUser | null, unknown> {
903
+ return Effect.gen(function* () {
904
+ const target = usernameOrId.trim().replace(/^@/, "");
905
+ if (!target) {
906
+ return null;
907
+ }
578
908
 
579
- let stdout: string;
580
- try {
581
- stdout = await runBirdJsonCommand([
909
+ const stdout = yield* runBirdJsonCommandEffect([
582
910
  "user",
583
911
  target,
584
912
  "--json",
585
913
  "--profile-only",
586
- ]);
587
- } catch (error) {
588
- if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
589
- throw error;
590
- }
591
- stdout = await runBirdJsonCommand([
592
- "user",
593
- target,
594
- "--json",
595
- "--count",
596
- "1",
597
- ]);
598
- }
599
- const payload = parseBirdJson(stdout) as BirdUserOverviewPayload;
600
- return toXurlMentionUser(payload.user);
914
+ ]).pipe(
915
+ Effect.catchAll((error) => {
916
+ if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
917
+ return Effect.fail(error);
918
+ }
919
+ return runBirdJsonCommandEffect([
920
+ "user",
921
+ target,
922
+ "--json",
923
+ "--count",
924
+ "1",
925
+ ]);
926
+ }),
927
+ );
928
+ const payload = (yield* parseBirdJsonEffect(
929
+ stdout,
930
+ )) as BirdUserOverviewPayload;
931
+ return toXurlMentionUser(payload.user);
932
+ });
933
+ }
934
+
935
+ export function lookupProfileViaBird(
936
+ usernameOrId: string,
937
+ ): Promise<XurlMentionUser | null> {
938
+ return runEffectPromise(lookupProfileViaBirdEffect(usernameOrId));
601
939
  }
602
940
 
603
941
  function toXurlMentionUser(
@@ -642,10 +980,11 @@ function toXurlMentionUser(
642
980
  };
643
981
  }
644
982
 
645
- export async function lookupProfilesViaBird(
983
+ export function lookupProfilesViaBirdEffect(
646
984
  usernameOrIds: string[],
647
- ): Promise<
648
- Array<{ target: string; user: XurlMentionUser | null; error?: string }>
985
+ ): Effect.Effect<
986
+ Array<{ target: string; user: XurlMentionUser | null; error?: string }>,
987
+ unknown
649
988
  > {
650
989
  const targets = Array.from(
651
990
  new Set(
@@ -655,56 +994,72 @@ export async function lookupProfilesViaBird(
655
994
  ),
656
995
  );
657
996
  if (targets.length === 0) {
658
- return [];
997
+ return Effect.succeed([]);
659
998
  }
660
999
 
661
- try {
662
- const stdout = await runBirdJsonCommand(["profiles", ...targets, "--json"]);
663
- const payload = parseBirdJson(stdout) as BirdProfilesPayload;
664
- const users = (payload.users ?? [])
665
- .map(toXurlMentionUser)
666
- .filter((user): user is XurlMentionUser => Boolean(user));
667
- const byTarget = new Map<string, XurlMentionUser>();
668
- for (const user of users) {
669
- byTarget.set(String(user.id), user);
670
- byTarget.set(user.username.toLowerCase(), user);
671
- }
672
- const errors = new Map(
673
- (payload.errors ?? []).map((item) => [
674
- String(item.target ?? "")
675
- .replace(/^@/, "")
676
- .toLowerCase(),
677
- item.error ?? "Unknown error",
678
- ]),
679
- );
680
- return targets.map((target) => ({
681
- target,
682
- user: byTarget.get(target.toLowerCase()) ?? null,
683
- ...(errors.has(target.toLowerCase())
684
- ? { error: errors.get(target.toLowerCase()) }
685
- : {}),
686
- }));
687
- } catch (error) {
688
- if (!isUnsupportedBirdOptionError(error, "profiles")) {
689
- throw error;
690
- }
691
- return Promise.all(
692
- targets.map(async (target) => {
693
- try {
694
- return { target, user: await lookupProfileViaBird(target) };
695
- } catch (lookupError) {
696
- return {
697
- target,
698
- user: null,
699
- error:
700
- lookupError instanceof Error
701
- ? lookupError.message
702
- : String(lookupError),
703
- };
1000
+ return runBirdJsonCommandEffect(["profiles", ...targets, "--json"]).pipe(
1001
+ Effect.flatMap((stdout) =>
1002
+ Effect.gen(function* () {
1003
+ const payload = (yield* parseBirdJsonEffect(
1004
+ stdout,
1005
+ )) as BirdProfilesPayload;
1006
+ const users = (payload.users ?? [])
1007
+ .map(toXurlMentionUser)
1008
+ .filter((user): user is XurlMentionUser => Boolean(user));
1009
+ const byTarget = new Map<string, XurlMentionUser>();
1010
+ for (const user of users) {
1011
+ byTarget.set(String(user.id), user);
1012
+ byTarget.set(user.username.toLowerCase(), user);
704
1013
  }
1014
+ const errors = new Map(
1015
+ (payload.errors ?? []).map((item) => [
1016
+ String(item.target ?? "")
1017
+ .replace(/^@/, "")
1018
+ .toLowerCase(),
1019
+ item.error ?? "Unknown error",
1020
+ ]),
1021
+ );
1022
+ return targets.map((target) => ({
1023
+ target,
1024
+ user: byTarget.get(target.toLowerCase()) ?? null,
1025
+ ...(errors.has(target.toLowerCase())
1026
+ ? { error: errors.get(target.toLowerCase()) }
1027
+ : {}),
1028
+ }));
705
1029
  }),
706
- );
707
- }
1030
+ ),
1031
+ Effect.catchAll((error) => {
1032
+ if (!isUnsupportedBirdOptionError(error, "profiles")) {
1033
+ return Effect.fail(error);
1034
+ }
1035
+ return Effect.forEach(
1036
+ targets,
1037
+ (target) =>
1038
+ lookupProfileViaBirdEffect(target).pipe(
1039
+ Effect.map((user) => ({ target, user })),
1040
+ Effect.catchAll((lookupError) =>
1041
+ Effect.succeed({
1042
+ target,
1043
+ user: null,
1044
+ error:
1045
+ lookupError instanceof Error
1046
+ ? lookupError.message
1047
+ : String(lookupError),
1048
+ }),
1049
+ ),
1050
+ ),
1051
+ { concurrency: "unbounded" },
1052
+ );
1053
+ }),
1054
+ );
1055
+ }
1056
+
1057
+ export function lookupProfilesViaBird(
1058
+ usernameOrIds: string[],
1059
+ ): Promise<
1060
+ Array<{ target: string; user: XurlMentionUser | null; error?: string }>
1061
+ > {
1062
+ return runEffectPromise(lookupProfilesViaBirdEffect(usernameOrIds));
708
1063
  }
709
1064
 
710
1065
  export const __test__ = {