birdclaw 0.2.1 → 0.4.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 (39) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +54 -15
  3. package/package.json +14 -15
  4. package/playwright.config.ts +5 -2
  5. package/src/cli.ts +289 -20
  6. package/src/lib/actions-transport.ts +58 -1
  7. package/src/lib/archive-import.ts +34 -2
  8. package/src/lib/backup.ts +271 -33
  9. package/src/lib/bird-actions.ts +21 -0
  10. package/src/lib/bird.ts +332 -17
  11. package/src/lib/blocks.ts +3 -3
  12. package/src/lib/bookmark-sync-job.ts +3 -3
  13. package/src/lib/config.ts +34 -1
  14. package/src/lib/db.ts +321 -14
  15. package/src/lib/dms-live.ts +3 -3
  16. package/src/lib/identity-search-index.ts +369 -0
  17. package/src/lib/mention-threads-live.ts +267 -0
  18. package/src/lib/mentions-live.ts +18 -7
  19. package/src/lib/moderation-target.ts +7 -5
  20. package/src/lib/moderation-write.ts +3 -3
  21. package/src/lib/profile-affiliation-hydration.ts +189 -0
  22. package/src/lib/profile-affiliations.ts +262 -0
  23. package/src/lib/profile-bio-entities.ts +318 -0
  24. package/src/lib/profile-history.ts +164 -0
  25. package/src/lib/profile-hydration.ts +8 -24
  26. package/src/lib/profile-resolver.ts +428 -0
  27. package/src/lib/queries.ts +522 -68
  28. package/src/lib/research.ts +607 -0
  29. package/src/lib/seed.ts +10 -4
  30. package/src/lib/sqlite.ts +143 -0
  31. package/src/lib/timeline-collections-live.ts +22 -8
  32. package/src/lib/timeline-live.ts +182 -0
  33. package/src/lib/tweet-account-edges.ts +39 -0
  34. package/src/lib/tweet-lookup.ts +35 -0
  35. package/src/lib/types.ts +103 -1
  36. package/src/lib/url-expansion.ts +147 -0
  37. package/src/lib/whois.ts +1060 -0
  38. package/src/lib/x-profile.ts +174 -17
  39. package/src/lib/xurl.ts +41 -6
package/src/lib/bird.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  import { getBirdCommand } from "./config";
4
7
  import type {
5
8
  XurlMentionData,
6
9
  XurlMentionsResponse,
7
10
  XurlMentionUser,
11
+ XurlReferencedTweet,
12
+ XurlTweetsResponse,
8
13
  } from "./types";
9
14
 
10
15
  const execFileAsync = promisify(execFile);
@@ -28,7 +33,9 @@ interface BirdTweetItem {
28
33
  retweetCount?: number;
29
34
  likeCount?: number;
30
35
  conversationId?: string;
31
- inReplyToStatusId?: string;
36
+ inReplyToStatusId?: string | null;
37
+ quotedStatusId?: string | null;
38
+ quotedTweet?: { id?: string | null } | null;
32
39
  author?: BirdTweetAuthor;
33
40
  authorId?: string;
34
41
  media?: BirdTweetMedia[];
@@ -66,6 +73,31 @@ export interface BirdDmsResponse {
66
73
  events: BirdDmEvent[];
67
74
  }
68
75
 
76
+ interface BirdUserOverviewPayload {
77
+ user?: {
78
+ id?: string;
79
+ username?: string;
80
+ name?: string;
81
+ description?: string;
82
+ location?: string;
83
+ url?: string;
84
+ verified?: boolean;
85
+ verifiedType?: string;
86
+ verified_type?: string;
87
+ followersCount?: number;
88
+ followingCount?: number;
89
+ profileImageUrl?: string;
90
+ createdAt?: string;
91
+ entities?: Record<string, unknown>;
92
+ affiliation?: Record<string, unknown>;
93
+ };
94
+ }
95
+
96
+ interface BirdProfilesPayload {
97
+ users?: NonNullable<BirdUserOverviewPayload["user"]>[];
98
+ errors?: Array<{ target?: string; error?: string }>;
99
+ }
100
+
69
101
  function toIsoTimestamp(value: string) {
70
102
  const parsed = new Date(value);
71
103
  if (Number.isNaN(parsed.getTime())) {
@@ -140,6 +172,51 @@ function parseBirdJson(stdout: string) {
140
172
  }
141
173
  }
142
174
 
175
+ function formatBirdCommandError(error: unknown, birdCommand: string) {
176
+ if (
177
+ error instanceof Error &&
178
+ "code" in error &&
179
+ (error as { code?: unknown }).code === "ENOENT"
180
+ ) {
181
+ return new Error(
182
+ `bird CLI not found at ${birdCommand}. Install @steipete/bird or set BIRDCLAW_BIRD_COMMAND / mentions.birdCommand to a valid bird binary.`,
183
+ );
184
+ }
185
+
186
+ return error;
187
+ }
188
+
189
+ function isUnsupportedBirdOptionError(error: unknown, option: string) {
190
+ if (!error || typeof error !== "object") {
191
+ return false;
192
+ }
193
+ const text = [
194
+ error instanceof Error ? error.message : "",
195
+ "stderr" in error && typeof error.stderr === "string" ? error.stderr : "",
196
+ "stdout" in error && typeof error.stdout === "string" ? error.stdout : "",
197
+ ].join("\n");
198
+ return text.includes(option) && /unknown option|error:/i.test(text);
199
+ }
200
+
201
+ async function runBirdJsonCommand(args: string[], timeoutMs?: number) {
202
+ const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
203
+ const stdoutPath = join(tempDir, "stdout.json");
204
+ const birdCommand = getBirdCommand();
205
+ try {
206
+ const shellScript = 'out="$1"; shift; exec "$@" > "$out"';
207
+ await execFileAsync(
208
+ "/bin/bash",
209
+ ["-lc", shellScript, "birdclaw-bird", stdoutPath, birdCommand, ...args],
210
+ { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
211
+ );
212
+ return readFileSync(stdoutPath, "utf8");
213
+ } catch (error) {
214
+ throw formatBirdCommandError(error, birdCommand);
215
+ } finally {
216
+ rmSync(tempDir, { recursive: true, force: true });
217
+ }
218
+ }
219
+
143
220
  function getBirdTweetItems(payload: unknown, command: string) {
144
221
  if (Array.isArray(payload)) {
145
222
  return payload as BirdTweetItem[];
@@ -156,6 +233,17 @@ function getBirdTweetItems(payload: unknown, command: string) {
156
233
  throw new Error(`bird ${command} returned unexpected JSON`);
157
234
  }
158
235
 
236
+ function getBirdTweetItem(payload: unknown, command: string) {
237
+ if (payload && typeof payload === "object") {
238
+ const record = payload as { id?: unknown };
239
+ if (typeof record.id === "string" && record.id.length > 0) {
240
+ return payload as BirdTweetItem;
241
+ }
242
+ }
243
+
244
+ throw new Error(`bird ${command} returned unexpected JSON`);
245
+ }
246
+
159
247
  function toMediaEntities(media: BirdTweetMedia[] | undefined) {
160
248
  if (!Array.isArray(media) || media.length === 0) {
161
249
  return undefined;
@@ -175,6 +263,25 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
175
263
  };
176
264
  }
177
265
 
266
+ function toReferencedTweets(item: BirdTweetItem) {
267
+ const references: XurlReferencedTweet[] = [];
268
+ if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
269
+ references.push({ type: "replied_to", id: item.inReplyToStatusId });
270
+ }
271
+
272
+ const quotedTweetId =
273
+ typeof item.quotedStatusId === "string" && item.quotedStatusId
274
+ ? item.quotedStatusId
275
+ : typeof item.quotedTweet?.id === "string" && item.quotedTweet.id
276
+ ? item.quotedTweet.id
277
+ : null;
278
+ if (quotedTweetId) {
279
+ references.push({ type: "quoted", id: quotedTweetId });
280
+ }
281
+
282
+ return references.length > 0 ? references : undefined;
283
+ }
284
+
178
285
  function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
179
286
  const users = new Map<string, XurlMentionUser>();
180
287
  const data = items.map((item): XurlMentionData => {
@@ -196,6 +303,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
196
303
  created_at: toIsoTimestamp(item.createdAt),
197
304
  conversation_id: item.conversationId ?? item.id,
198
305
  entities: toMediaEntities(item.media),
306
+ referenced_tweets: toReferencedTweets(item),
199
307
  public_metrics: {
200
308
  reply_count: Number(item.replyCount ?? 0),
201
309
  retweet_count: Number(item.retweetCount ?? 0),
@@ -224,12 +332,12 @@ export async function listMentionsViaBird({
224
332
  }: {
225
333
  maxResults: number;
226
334
  }): Promise<XurlMentionsResponse> {
227
- const birdCommand = getBirdCommand();
228
- const { stdout } = await execFileAsync(
229
- birdCommand,
230
- ["mentions", "-n", String(maxResults), "--json"],
231
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
232
- );
335
+ const stdout = await runBirdJsonCommand([
336
+ "mentions",
337
+ "-n",
338
+ String(maxResults),
339
+ "--json",
340
+ ]);
233
341
  const payload = parseBirdJson(stdout);
234
342
 
235
343
  return normalizeBirdTweets(getBirdTweetItems(payload, "mentions"));
@@ -246,7 +354,6 @@ async function listTweetsViaBirdCommand({
246
354
  all?: boolean;
247
355
  maxPages?: number;
248
356
  }): Promise<XurlMentionsResponse> {
249
- const birdCommand = getBirdCommand();
250
357
  const args = [command, "-n", String(maxResults), "--json"];
251
358
  if (all) {
252
359
  args.push("--all");
@@ -254,9 +361,7 @@ async function listTweetsViaBirdCommand({
254
361
  if (maxPages !== undefined) {
255
362
  args.push("--max-pages", String(maxPages));
256
363
  }
257
- const { stdout } = await execFileAsync(birdCommand, args, {
258
- maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES,
259
- });
364
+ const stdout = await runBirdJsonCommand(args);
260
365
  const payload = parseBirdJson(stdout);
261
366
 
262
367
  return normalizeBirdTweets(getBirdTweetItems(payload, command));
@@ -296,17 +401,75 @@ export async function listBookmarkedTweetsViaBird({
296
401
  });
297
402
  }
298
403
 
404
+ export async function lookupTweetsByIdsViaBird(
405
+ ids: string[],
406
+ ): Promise<XurlTweetsResponse> {
407
+ if (ids.length === 0) {
408
+ return { data: [] };
409
+ }
410
+
411
+ const tweets = await Promise.all(
412
+ ids.map(async (id) => {
413
+ const stdout = await runBirdJsonCommand(["read", id, "--json"]);
414
+ return getBirdTweetItem(parseBirdJson(stdout), "read");
415
+ }),
416
+ );
417
+
418
+ return normalizeBirdTweets(tweets);
419
+ }
420
+
421
+ export async function listHomeTimelineViaBird({
422
+ maxResults,
423
+ following = true,
424
+ }: {
425
+ maxResults: number;
426
+ following?: boolean;
427
+ }): Promise<XurlMentionsResponse> {
428
+ const args = ["home", "-n", String(maxResults), "--json"];
429
+ if (following) {
430
+ args.push("--following");
431
+ }
432
+ const stdout = await runBirdJsonCommand(args);
433
+ const payload = parseBirdJson(stdout);
434
+
435
+ return normalizeBirdTweets(getBirdTweetItems(payload, "home"));
436
+ }
437
+
438
+ export async function listThreadViaBird({
439
+ tweetId,
440
+ all,
441
+ maxPages,
442
+ timeoutMs,
443
+ }: {
444
+ tweetId: string;
445
+ all?: boolean;
446
+ maxPages?: number;
447
+ timeoutMs?: number;
448
+ }): Promise<XurlMentionsResponse> {
449
+ const args = ["thread", tweetId, "--json"];
450
+ if (all) {
451
+ args.push("--all");
452
+ }
453
+ if (maxPages !== undefined) {
454
+ args.push("--max-pages", String(maxPages));
455
+ }
456
+ const stdout = await runBirdJsonCommand(args, timeoutMs);
457
+ const payload = parseBirdJson(stdout);
458
+
459
+ return normalizeBirdTweets(getBirdTweetItems(payload, "thread"));
460
+ }
461
+
299
462
  export async function listDirectMessagesViaBird({
300
463
  maxResults,
301
464
  }: {
302
465
  maxResults: number;
303
466
  }): Promise<BirdDmsResponse> {
304
- const birdCommand = getBirdCommand();
305
- const { stdout } = await execFileAsync(
306
- birdCommand,
307
- ["dms", "-n", String(maxResults), "--json"],
308
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
309
- );
467
+ const stdout = await runBirdJsonCommand([
468
+ "dms",
469
+ "-n",
470
+ String(maxResults),
471
+ "--json",
472
+ ]);
310
473
  const payload = parseBirdJson(stdout);
311
474
  if (
312
475
  !payload ||
@@ -320,3 +483,155 @@ export async function listDirectMessagesViaBird({
320
483
 
321
484
  return payload as BirdDmsResponse;
322
485
  }
486
+
487
+ export async function lookupProfileViaBird(
488
+ usernameOrId: string,
489
+ ): Promise<XurlMentionUser | null> {
490
+ const target = usernameOrId.trim().replace(/^@/, "");
491
+ if (!target) {
492
+ return null;
493
+ }
494
+
495
+ let stdout: string;
496
+ try {
497
+ stdout = await runBirdJsonCommand([
498
+ "user",
499
+ target,
500
+ "--json",
501
+ "--profile-only",
502
+ ]);
503
+ } catch (error) {
504
+ if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
505
+ throw error;
506
+ }
507
+ stdout = await runBirdJsonCommand([
508
+ "user",
509
+ target,
510
+ "--json",
511
+ "--count",
512
+ "1",
513
+ ]);
514
+ }
515
+ const payload = parseBirdJson(stdout) as BirdUserOverviewPayload;
516
+ return toXurlMentionUser(payload.user);
517
+ }
518
+
519
+ function toXurlMentionUser(
520
+ user: BirdUserOverviewPayload["user"],
521
+ ): XurlMentionUser | null {
522
+ if (!user?.id || !user.username) {
523
+ return null;
524
+ }
525
+
526
+ return {
527
+ id: String(user.id),
528
+ username: String(user.username).replace(/^@/, ""),
529
+ name: String(user.name ?? user.username),
530
+ description:
531
+ typeof user.description === "string" ? user.description : undefined,
532
+ location: typeof user.location === "string" ? user.location : undefined,
533
+ url: typeof user.url === "string" ? user.url : undefined,
534
+ verified: typeof user.verified === "boolean" ? user.verified : undefined,
535
+ verified_type:
536
+ typeof user.verifiedType === "string"
537
+ ? user.verifiedType
538
+ : typeof user.verified_type === "string"
539
+ ? user.verified_type
540
+ : undefined,
541
+ profile_image_url:
542
+ typeof user.profileImageUrl === "string"
543
+ ? user.profileImageUrl
544
+ : undefined,
545
+ entities:
546
+ user.entities && typeof user.entities === "object"
547
+ ? user.entities
548
+ : undefined,
549
+ affiliation:
550
+ user.affiliation && typeof user.affiliation === "object"
551
+ ? user.affiliation
552
+ : undefined,
553
+ created_at: typeof user.createdAt === "string" ? user.createdAt : undefined,
554
+ public_metrics: {
555
+ followers_count: Number(user.followersCount ?? 0),
556
+ following_count: Number(user.followingCount ?? 0),
557
+ },
558
+ };
559
+ }
560
+
561
+ export async function lookupProfilesViaBird(
562
+ usernameOrIds: string[],
563
+ ): Promise<
564
+ Array<{ target: string; user: XurlMentionUser | null; error?: string }>
565
+ > {
566
+ const targets = Array.from(
567
+ new Set(
568
+ usernameOrIds
569
+ .map((target) => target.trim().replace(/^@/, ""))
570
+ .filter((target) => target.length > 0),
571
+ ),
572
+ );
573
+ if (targets.length === 0) {
574
+ return [];
575
+ }
576
+
577
+ try {
578
+ const stdout = await runBirdJsonCommand(["profiles", ...targets, "--json"]);
579
+ const payload = parseBirdJson(stdout) as BirdProfilesPayload;
580
+ const users = (payload.users ?? [])
581
+ .map(toXurlMentionUser)
582
+ .filter((user): user is XurlMentionUser => Boolean(user));
583
+ const byTarget = new Map<string, XurlMentionUser>();
584
+ for (const user of users) {
585
+ byTarget.set(String(user.id), user);
586
+ byTarget.set(user.username.toLowerCase(), user);
587
+ }
588
+ const errors = new Map(
589
+ (payload.errors ?? []).map((item) => [
590
+ String(item.target ?? "")
591
+ .replace(/^@/, "")
592
+ .toLowerCase(),
593
+ item.error ?? "Unknown error",
594
+ ]),
595
+ );
596
+ return targets.map((target) => ({
597
+ target,
598
+ user: byTarget.get(target.toLowerCase()) ?? null,
599
+ ...(errors.has(target.toLowerCase())
600
+ ? { error: errors.get(target.toLowerCase()) }
601
+ : {}),
602
+ }));
603
+ } catch (error) {
604
+ if (!isUnsupportedBirdOptionError(error, "profiles")) {
605
+ throw error;
606
+ }
607
+ return Promise.all(
608
+ targets.map(async (target) => {
609
+ try {
610
+ return { target, user: await lookupProfileViaBird(target) };
611
+ } catch (lookupError) {
612
+ return {
613
+ target,
614
+ user: null,
615
+ error:
616
+ lookupError instanceof Error
617
+ ? lookupError.message
618
+ : String(lookupError),
619
+ };
620
+ }
621
+ }),
622
+ );
623
+ }
624
+ }
625
+
626
+ export const __test__ = {
627
+ toIsoTimestamp,
628
+ escapeJsonStringControlChars,
629
+ parseBirdJson,
630
+ formatBirdCommandError,
631
+ isUnsupportedBirdOptionError,
632
+ getBirdTweetItems,
633
+ getBirdTweetItem,
634
+ toMediaEntities,
635
+ toReferencedTweets,
636
+ normalizeBirdTweets,
637
+ };
package/src/lib/blocks.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
  import { getNativeDb } from "./db";
3
3
  import {
4
4
  getAccountHandle,
@@ -16,7 +16,7 @@ function remoteBlockSyncDisabled() {
16
16
  }
17
17
 
18
18
  function upsertRemoteBlock(
19
- db: Database.Database,
19
+ db: Database,
20
20
  accountId: string,
21
21
  profileId: string,
22
22
  blockedAt: string,
@@ -33,7 +33,7 @@ function upsertRemoteBlock(
33
33
  }
34
34
 
35
35
  function pruneRemoteBlocks(
36
- db: Database.Database,
36
+ db: Database,
37
37
  accountId: string,
38
38
  profileIds: string[],
39
39
  ) {
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
- import type Database from "better-sqlite3";
6
+ import type { Database } from "./sqlite";
7
7
  import { maybeAutoSyncBackup, type BackupAutoUpdateResult } from "./backup";
8
8
  import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
9
9
  import { getNativeDb } from "./db";
@@ -31,7 +31,7 @@ export interface BookmarkSyncJobOptions {
31
31
  cacheTtlMs?: number;
32
32
  logPath?: string;
33
33
  lockPath?: string;
34
- db?: Database.Database;
34
+ db?: Database;
35
35
  }
36
36
 
37
37
  export interface BookmarkSyncAuditEntry {
@@ -117,7 +117,7 @@ export function getDefaultBookmarkSyncLockPath() {
117
117
  return path.join(getBirdclawPaths().rootDir, "locks", "bookmarks-sync.lock");
118
118
  }
119
119
 
120
- function countBookmarks(db: Database.Database) {
120
+ function countBookmarks(db: Database) {
121
121
  const row = db
122
122
  .prepare("select count(*) as count from tweet_collections where kind = ?")
123
123
  .get("bookmarks") as { count: number };
package/src/lib/config.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
1
+ import {
2
+ accessSync,
3
+ constants,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ } from "node:fs";
2
8
  import os from "node:os";
3
9
  import path from "node:path";
4
10
 
@@ -128,6 +134,28 @@ export function resolveActionsTransport(
128
134
  return "auto";
129
135
  }
130
136
 
137
+ function findCommandOnPath(command: string) {
138
+ const pathValue = process.env.PATH;
139
+ if (!pathValue) {
140
+ return undefined;
141
+ }
142
+
143
+ for (const directory of pathValue.split(path.delimiter)) {
144
+ if (!directory) {
145
+ continue;
146
+ }
147
+ const candidate = path.join(directory, command);
148
+ try {
149
+ accessSync(candidate, constants.X_OK);
150
+ return candidate;
151
+ } catch {
152
+ continue;
153
+ }
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
131
159
  export function getBirdCommand() {
132
160
  const envCommand = process.env.BIRDCLAW_BIRD_COMMAND?.trim();
133
161
  if (envCommand) {
@@ -139,6 +167,11 @@ export function getBirdCommand() {
139
167
  return configuredCommand;
140
168
  }
141
169
 
170
+ const pathCommand = findCommandOnPath("bird");
171
+ if (pathCommand) {
172
+ return pathCommand;
173
+ }
174
+
142
175
  return path.join(os.homedir(), "Projects", "bird", "bird");
143
176
  }
144
177