birdclaw 0.4.0 → 0.5.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 (64) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +108 -7
  3. package/package.json +24 -23
  4. package/playwright.config.ts +1 -0
  5. package/public/favicon.ico +0 -0
  6. package/public/logo192.png +0 -0
  7. package/public/logo512.png +0 -0
  8. package/public/manifest.json +2 -2
  9. package/src/cli.ts +563 -18
  10. package/src/components/AppNav.tsx +66 -29
  11. package/src/components/AvatarChip.tsx +10 -5
  12. package/src/components/ConversationThread.tsx +125 -0
  13. package/src/components/DmWorkspace.tsx +96 -98
  14. package/src/components/EmbeddedTweetCard.tsx +20 -14
  15. package/src/components/InboxCard.tsx +92 -90
  16. package/src/components/LinkPreviewCard.tsx +270 -0
  17. package/src/components/ProfilePreview.tsx +8 -3
  18. package/src/components/SavedTimelineView.tsx +48 -38
  19. package/src/components/TimelineCard.tsx +228 -84
  20. package/src/components/TweetRichText.tsx +6 -2
  21. package/src/lib/archive-import.ts +1567 -65
  22. package/src/lib/authored-live.ts +1074 -0
  23. package/src/lib/backup.ts +318 -14
  24. package/src/lib/bird-actions.ts +1 -10
  25. package/src/lib/bird-command.ts +57 -0
  26. package/src/lib/bird.ts +89 -5
  27. package/src/lib/config.ts +1 -1
  28. package/src/lib/db.ts +282 -4
  29. package/src/lib/follow-graph.ts +1053 -0
  30. package/src/lib/link-index.ts +629 -0
  31. package/src/lib/link-insights.ts +834 -0
  32. package/src/lib/link-preview-metadata.ts +334 -0
  33. package/src/lib/media-fetch.ts +787 -0
  34. package/src/lib/media-includes.ts +165 -0
  35. package/src/lib/mention-threads-live.ts +535 -43
  36. package/src/lib/mentions-live.ts +623 -19
  37. package/src/lib/moderation-target.ts +6 -0
  38. package/src/lib/profile-hydration.ts +1 -1
  39. package/src/lib/profile-resolver.ts +115 -1
  40. package/src/lib/queries.ts +233 -7
  41. package/src/lib/seed.ts +127 -8
  42. package/src/lib/timeline-collections-live.ts +145 -22
  43. package/src/lib/timeline-live.ts +10 -15
  44. package/src/lib/tweet-account-edges.ts +9 -2
  45. package/src/lib/tweet-render.ts +97 -0
  46. package/src/lib/types.ts +219 -2
  47. package/src/lib/ui.ts +375 -147
  48. package/src/lib/url-expansion-store.ts +110 -0
  49. package/src/lib/url-expansion.ts +33 -8
  50. package/src/lib/x-profile.ts +7 -2
  51. package/src/lib/xurl.ts +296 -12
  52. package/src/routeTree.gen.ts +105 -0
  53. package/src/routes/__root.tsx +7 -3
  54. package/src/routes/api/conversation.tsx +34 -0
  55. package/src/routes/api/link-insights.tsx +79 -0
  56. package/src/routes/api/link-preview.tsx +43 -0
  57. package/src/routes/api/profile-hydrate.tsx +51 -0
  58. package/src/routes/blocks.tsx +111 -86
  59. package/src/routes/dms.tsx +90 -78
  60. package/src/routes/inbox.tsx +98 -86
  61. package/src/routes/index.tsx +63 -50
  62. package/src/routes/links.tsx +883 -0
  63. package/src/routes/mentions.tsx +63 -50
  64. package/src/styles.css +106 -43
@@ -0,0 +1,1074 @@
1
+ import type { Database } from "./sqlite";
2
+ import { getNativeDb } from "./db";
3
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
4
+ import type {
5
+ TweetEntities,
6
+ TweetMediaItem,
7
+ XurlMedia,
8
+ XurlMentionData,
9
+ XurlMentionUser,
10
+ XurlTweetData,
11
+ XurlTweetIncludes,
12
+ XurlUserTweet,
13
+ XurlUserTweetsResponse,
14
+ } from "./types";
15
+ import { upsertTweetAccountEdge } from "./tweet-account-edges";
16
+ import {
17
+ buildExternalProfileId,
18
+ ensureStubProfileForXUser,
19
+ upsertProfileFromXUser,
20
+ } from "./x-profile";
21
+ import {
22
+ getTransportStatus,
23
+ listUserTweets,
24
+ lookupAuthenticatedUser,
25
+ } from "./xurl";
26
+
27
+ export type AuthoredSyncMode = "xurl";
28
+
29
+ export class AuthoredSyncError extends Error {
30
+ constructor(
31
+ message: string,
32
+ public readonly exitCode: number,
33
+ ) {
34
+ super(message);
35
+ this.name = "AuthoredSyncError";
36
+ }
37
+ }
38
+
39
+ // sync_cache JSON shapes:
40
+ // { state: "committed", sinceId }
41
+ // { state: "pending-forward", sinceId, token, pendingNewestId }
42
+ // { state: "pending-until", sinceId, token, untilId, requestedSinceId }
43
+ type AuthoredCursorState =
44
+ | { state: "committed"; sinceId: string | null }
45
+ | {
46
+ state: "pending-forward";
47
+ sinceId: string | null;
48
+ token: string;
49
+ pendingNewestId: string | null;
50
+ }
51
+ | {
52
+ state: "pending-until";
53
+ sinceId: string | null;
54
+ token: string;
55
+ untilId: string;
56
+ requestedSinceId?: string | null;
57
+ };
58
+
59
+ interface AuthoredPayload {
60
+ data: XurlMentionData[];
61
+ includes?: XurlTweetIncludes;
62
+ meta: {
63
+ result_count: number;
64
+ page_count: number;
65
+ next_token: string | null;
66
+ newest_id?: string;
67
+ oldest_id?: string;
68
+ };
69
+ }
70
+
71
+ const MIN_XURL_LIMIT = 5;
72
+ const MAX_XURL_LIMIT = 100;
73
+ const DEFAULT_LIMIT = 100;
74
+ const AUTHORED_CURSOR_PREFIX = "authored:xurl";
75
+ const AUTHORED_TWEET_FIELDS = [
76
+ "author_id",
77
+ "created_at",
78
+ "conversation_id",
79
+ "entities",
80
+ "attachments",
81
+ "public_metrics",
82
+ "referenced_tweets",
83
+ ];
84
+ const AUTHORED_EXPANSIONS = [
85
+ "author_id",
86
+ "referenced_tweets.id",
87
+ "referenced_tweets.id.author_id",
88
+ "attachments.media_keys",
89
+ ];
90
+ const AUTHORED_USER_FIELDS = [
91
+ "description",
92
+ "entities",
93
+ "location",
94
+ "public_metrics",
95
+ "profile_image_url",
96
+ "url",
97
+ "created_at",
98
+ "verified",
99
+ "verified_type",
100
+ ];
101
+ const AUTHORED_MEDIA_FIELDS = [
102
+ "media_key",
103
+ "type",
104
+ "url",
105
+ "preview_image_url",
106
+ "width",
107
+ "height",
108
+ "alt_text",
109
+ ];
110
+
111
+ function assertXurlLimit(limit: number) {
112
+ if (
113
+ !Number.isFinite(limit) ||
114
+ limit < MIN_XURL_LIMIT ||
115
+ limit > MAX_XURL_LIMIT
116
+ ) {
117
+ throw new Error("xurl mode requires --limit between 5 and 100");
118
+ }
119
+ return Math.floor(limit);
120
+ }
121
+
122
+ function parseMaxPages(value?: number) {
123
+ if (value === undefined) {
124
+ return null;
125
+ }
126
+ if (!Number.isFinite(value) || value < 1) {
127
+ throw new Error("--max-pages must be at least 1");
128
+ }
129
+ return Math.floor(value);
130
+ }
131
+
132
+ function cursorKey(accountId: string) {
133
+ return `${AUTHORED_CURSOR_PREFIX}:${accountId}:cursor`;
134
+ }
135
+
136
+ function normalizeCursor(value: unknown): AuthoredCursorState {
137
+ if (!value || typeof value !== "object") {
138
+ return { state: "committed", sinceId: null };
139
+ }
140
+ const record = value as Record<string, unknown>;
141
+ const sinceId = typeof record.sinceId === "string" ? record.sinceId : null;
142
+ if (record.state === "pending-forward" && typeof record.token === "string") {
143
+ return {
144
+ state: "pending-forward",
145
+ sinceId,
146
+ token: record.token,
147
+ pendingNewestId:
148
+ typeof record.pendingNewestId === "string"
149
+ ? record.pendingNewestId
150
+ : null,
151
+ };
152
+ }
153
+ if (
154
+ record.state === "pending-until" &&
155
+ typeof record.token === "string" &&
156
+ typeof record.untilId === "string"
157
+ ) {
158
+ const requestedSinceId =
159
+ "requestedSinceId" in record
160
+ ? typeof record.requestedSinceId === "string"
161
+ ? record.requestedSinceId
162
+ : null
163
+ : undefined;
164
+ return {
165
+ state: "pending-until",
166
+ sinceId,
167
+ token: record.token,
168
+ untilId: record.untilId,
169
+ ...(requestedSinceId !== undefined ? { requestedSinceId } : {}),
170
+ };
171
+ }
172
+ const legacyToken =
173
+ typeof record.paginationToken === "string" ? record.paginationToken : null;
174
+ if (legacyToken) {
175
+ return {
176
+ state: "pending-forward",
177
+ sinceId,
178
+ token: legacyToken,
179
+ pendingNewestId:
180
+ typeof record.pendingNewestId === "string"
181
+ ? record.pendingNewestId
182
+ : null,
183
+ };
184
+ }
185
+ return { state: "committed", sinceId };
186
+ }
187
+
188
+ function readAuthoredCursor(db: Database, accountId: string) {
189
+ return normalizeCursor(readSyncCache(cursorKey(accountId), db)?.value);
190
+ }
191
+
192
+ function writeAuthoredCursor(
193
+ db: Database,
194
+ accountId: string,
195
+ state: AuthoredCursorState,
196
+ ) {
197
+ writeSyncCache(cursorKey(accountId), state, db);
198
+ }
199
+
200
+ function writeCommittedCursor(
201
+ db: Database,
202
+ accountId: string,
203
+ sinceId: string | null,
204
+ ) {
205
+ writeAuthoredCursor(db, accountId, { state: "committed", sinceId });
206
+ }
207
+
208
+ function writePendingForwardCursor(
209
+ db: Database,
210
+ accountId: string,
211
+ {
212
+ sinceId,
213
+ token,
214
+ pendingNewestId,
215
+ }: {
216
+ sinceId: string | null;
217
+ token: string;
218
+ pendingNewestId: string | null;
219
+ },
220
+ ) {
221
+ writeAuthoredCursor(db, accountId, {
222
+ state: "pending-forward",
223
+ sinceId,
224
+ token,
225
+ pendingNewestId,
226
+ });
227
+ }
228
+
229
+ function writePendingUntilCursor(
230
+ db: Database,
231
+ accountId: string,
232
+ {
233
+ sinceId,
234
+ token,
235
+ untilId,
236
+ requestedSinceId,
237
+ }: {
238
+ sinceId: string | null;
239
+ token: string;
240
+ untilId: string;
241
+ requestedSinceId: string | null;
242
+ },
243
+ ) {
244
+ writeAuthoredCursor(db, accountId, {
245
+ state: "pending-until",
246
+ sinceId,
247
+ token,
248
+ untilId,
249
+ requestedSinceId,
250
+ });
251
+ }
252
+
253
+ // Archive seeds stay archive-only because backups can contain live edges without sync_cache.
254
+ function findArchiveAuthoredSinceSeed(db: Database, accountId: string) {
255
+ const row = db
256
+ .prepare(
257
+ `
258
+ select t.id
259
+ from tweets t
260
+ join accounts a on a.id = ?
261
+ where t.id glob '[0-9]*'
262
+ and t.id not glob '*[^0-9]*'
263
+ and (
264
+ exists (
265
+ select 1
266
+ from tweet_account_edges e
267
+ where e.account_id = ?
268
+ and e.tweet_id = t.id
269
+ and e.kind = 'authored'
270
+ and e.source = 'archive'
271
+ )
272
+ or (
273
+ t.kind = 'home'
274
+ and exists (
275
+ select 1
276
+ from tweet_account_edges e2
277
+ where e2.account_id = ?
278
+ and e2.tweet_id = t.id
279
+ and e2.source = 'archive'
280
+ and e2.kind = 'home'
281
+ )
282
+ and t.author_profile_id in ('profile_me', 'profile_user_' || a.external_user_id)
283
+ )
284
+ )
285
+ order by length(t.id) desc, t.id desc
286
+ limit 1
287
+ `,
288
+ )
289
+ .get(accountId, accountId, accountId) as { id: string } | undefined;
290
+ return row?.id ?? null;
291
+ }
292
+
293
+ function compareTweetIds(
294
+ left: string | null | undefined,
295
+ right: string | null | undefined,
296
+ ) {
297
+ if (!left && !right) {
298
+ return 0;
299
+ }
300
+ if (!left) {
301
+ return -1;
302
+ }
303
+ if (!right) {
304
+ return 1;
305
+ }
306
+ try {
307
+ const leftBigInt = BigInt(left);
308
+ const rightBigInt = BigInt(right);
309
+ return leftBigInt === rightBigInt ? 0 : leftBigInt > rightBigInt ? 1 : -1;
310
+ } catch {
311
+ if (left.length !== right.length) {
312
+ return left.length > right.length ? 1 : -1;
313
+ }
314
+ return left.localeCompare(right);
315
+ }
316
+ }
317
+
318
+ function maxTweetId(...ids: Array<string | null | undefined>) {
319
+ return ids.reduce<string | null>((current, next) => {
320
+ if (!next) {
321
+ return current;
322
+ }
323
+ return compareTweetIds(next, current) > 0 ? next : current;
324
+ }, null);
325
+ }
326
+
327
+ function getNewestTweetId(tweets: XurlMentionData[]) {
328
+ return maxTweetId(...tweets.map((tweet) => tweet.id));
329
+ }
330
+
331
+ function getOldestTweetId(tweets: XurlMentionData[]) {
332
+ return tweets.reduce<string | null>((current, tweet) => {
333
+ if (!current) {
334
+ return tweet.id;
335
+ }
336
+ return compareTweetIds(tweet.id, current) < 0 ? tweet.id : current;
337
+ }, null);
338
+ }
339
+
340
+ function resolveAccount(db: Database, accountId?: string) {
341
+ const row = accountId
342
+ ? (db
343
+ .prepare(
344
+ "select id, handle, external_user_id from accounts where id = ?",
345
+ )
346
+ .get(accountId) as
347
+ | { id: string; handle: string; external_user_id: string | null }
348
+ | undefined)
349
+ : (db
350
+ .prepare(
351
+ `
352
+ select id, handle, external_user_id
353
+ from accounts
354
+ order by is_default desc, created_at asc
355
+ limit 1
356
+ `,
357
+ )
358
+ .get() as
359
+ | { id: string; handle: string; external_user_id: string | null }
360
+ | undefined);
361
+
362
+ if (!row) {
363
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
364
+ }
365
+
366
+ return {
367
+ accountId: row.id,
368
+ username: row.handle.replace(/^@/, ""),
369
+ externalUserId:
370
+ typeof row.external_user_id === "string" &&
371
+ row.external_user_id.length > 0
372
+ ? row.external_user_id
373
+ : undefined,
374
+ };
375
+ }
376
+
377
+ function normalizeUsername(value: string) {
378
+ return value.replace(/^@/, "").trim().toLowerCase();
379
+ }
380
+
381
+ function persistAccountExternalUserId(
382
+ db: Database,
383
+ accountId: string,
384
+ externalUserId: string,
385
+ ) {
386
+ db.prepare(
387
+ `
388
+ update accounts
389
+ set external_user_id = ?
390
+ where id = ?
391
+ and (external_user_id is null or external_user_id = '')
392
+ `,
393
+ ).run(externalUserId, accountId);
394
+ }
395
+
396
+ function userFromAuthenticatedPayload(
397
+ payload: Record<string, unknown> | null,
398
+ ): XurlMentionUser | undefined {
399
+ if (!payload || typeof payload.id !== "string") {
400
+ return undefined;
401
+ }
402
+ const username =
403
+ typeof payload.username === "string"
404
+ ? payload.username.replace(/^@/, "")
405
+ : "";
406
+ if (!username) {
407
+ return undefined;
408
+ }
409
+ return {
410
+ id: payload.id,
411
+ username,
412
+ name: typeof payload.name === "string" ? payload.name : username,
413
+ };
414
+ }
415
+
416
+ async function resolveAuthoredIdentity({
417
+ account,
418
+ db,
419
+ }: {
420
+ account?: string;
421
+ db: Database;
422
+ }) {
423
+ const status = await getTransportStatus();
424
+ if (status.availableTransport !== "xurl") {
425
+ throw new AuthoredSyncError(status.statusText, 4);
426
+ }
427
+
428
+ const resolvedAccount = resolveAccount(db, account);
429
+ if (resolvedAccount.externalUserId) {
430
+ return {
431
+ ...resolvedAccount,
432
+ userId: resolvedAccount.externalUserId,
433
+ authenticatedUser: undefined,
434
+ };
435
+ }
436
+
437
+ const authenticated = await lookupAuthenticatedUser();
438
+ const authenticatedUser = userFromAuthenticatedPayload(authenticated);
439
+ if (!authenticatedUser?.id) {
440
+ throw new AuthoredSyncError(
441
+ "Could not resolve authenticated Twitter user id",
442
+ 4,
443
+ );
444
+ }
445
+
446
+ if (
447
+ normalizeUsername(authenticatedUser.username) !==
448
+ normalizeUsername(resolvedAccount.username)
449
+ ) {
450
+ throw new AuthoredSyncError(
451
+ `xurl is authenticated as @${authenticatedUser.username}, but selected account ${resolvedAccount.accountId} is @${resolvedAccount.username}. Link the account external_user_id or switch xurl login before syncing authored tweets.`,
452
+ 4,
453
+ );
454
+ }
455
+
456
+ persistAccountExternalUserId(
457
+ db,
458
+ resolvedAccount.accountId,
459
+ authenticatedUser.id,
460
+ );
461
+
462
+ return {
463
+ ...resolvedAccount,
464
+ userId: authenticatedUser.id,
465
+ authenticatedUser,
466
+ };
467
+ }
468
+
469
+ function toFallbackUser({
470
+ userId,
471
+ username,
472
+ authenticatedUser,
473
+ }: {
474
+ userId: string;
475
+ username: string;
476
+ authenticatedUser?: XurlMentionUser;
477
+ }): XurlMentionUser {
478
+ if (authenticatedUser?.id === userId) {
479
+ return authenticatedUser;
480
+ }
481
+ return {
482
+ id: userId,
483
+ username: username || `user_${userId}`,
484
+ name: username || `user_${userId}`,
485
+ };
486
+ }
487
+
488
+ function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
489
+ return {
490
+ ...tweet,
491
+ author_id: tweet.author_id ?? fallbackAuthorId,
492
+ } satisfies XurlMentionData;
493
+ }
494
+
495
+ function toLocalEntities(tweet: XurlMentionData): TweetEntities {
496
+ const raw = tweet.entities;
497
+ if (!raw || typeof raw !== "object") {
498
+ return {};
499
+ }
500
+
501
+ const entities = raw as Record<string, unknown>;
502
+ const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
503
+ const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
504
+ const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
505
+
506
+ return {
507
+ ...(rawMentions.length
508
+ ? {
509
+ mentions: rawMentions.map((mention) => {
510
+ const value =
511
+ mention && typeof mention === "object"
512
+ ? (mention as Record<string, unknown>)
513
+ : {};
514
+ return {
515
+ username: String(value.username ?? ""),
516
+ id: typeof value.id === "string" ? String(value.id) : undefined,
517
+ start: Number(value.start ?? 0),
518
+ end: Number(value.end ?? 0),
519
+ };
520
+ }),
521
+ }
522
+ : {}),
523
+ ...(rawUrls.length
524
+ ? {
525
+ urls: rawUrls.map((url) => {
526
+ const value =
527
+ url && typeof url === "object"
528
+ ? (url as Record<string, unknown>)
529
+ : {};
530
+ return {
531
+ url: String(value.url ?? ""),
532
+ expandedUrl: String(value.expanded_url ?? value.url ?? ""),
533
+ displayUrl: String(
534
+ value.display_url ?? value.expanded_url ?? value.url ?? "",
535
+ ),
536
+ start: Number(value.start ?? 0),
537
+ end: Number(value.end ?? 0),
538
+ };
539
+ }),
540
+ }
541
+ : {}),
542
+ ...(rawHashtags.length
543
+ ? {
544
+ hashtags: rawHashtags.map((hashtag) => {
545
+ const value =
546
+ hashtag && typeof hashtag === "object"
547
+ ? (hashtag as Record<string, unknown>)
548
+ : {};
549
+ return {
550
+ tag: String(value.tag ?? ""),
551
+ start: Number(value.start ?? 0),
552
+ end: Number(value.end ?? 0),
553
+ };
554
+ }),
555
+ }
556
+ : {}),
557
+ };
558
+ }
559
+
560
+ function toMediaType(type: string): TweetMediaItem["type"] {
561
+ if (type === "photo" || type === "image") {
562
+ return "image";
563
+ }
564
+ if (type === "animated_gif" || type === "gif") {
565
+ return "gif";
566
+ }
567
+ return type === "video" ? "video" : "unknown";
568
+ }
569
+
570
+ function toLocalMedia(
571
+ tweet: XurlMentionData,
572
+ mediaByKey: Map<string, XurlMedia>,
573
+ ) {
574
+ const mediaKeys = tweet.attachments?.media_keys ?? [];
575
+ const seen = new Set<string>();
576
+ return mediaKeys
577
+ .map((mediaKey) => mediaByKey.get(mediaKey))
578
+ .filter((media): media is XurlMedia => Boolean(media))
579
+ .map((media) => {
580
+ const url = media.url ?? media.preview_image_url ?? "";
581
+ const thumbnailUrl = media.preview_image_url ?? media.url ?? "";
582
+ return {
583
+ url,
584
+ type: toMediaType(media.type),
585
+ ...(typeof media.alt_text === "string"
586
+ ? { altText: media.alt_text }
587
+ : {}),
588
+ ...(typeof media.width === "number" ? { width: media.width } : {}),
589
+ ...(typeof media.height === "number" ? { height: media.height } : {}),
590
+ ...(thumbnailUrl ? { thumbnailUrl } : {}),
591
+ };
592
+ })
593
+ .filter((media) => {
594
+ const key = media.url || media.thumbnailUrl;
595
+ if (!key || seen.has(key)) {
596
+ return false;
597
+ }
598
+ seen.add(key);
599
+ return true;
600
+ });
601
+ }
602
+
603
+ function getMediaCount(tweet: XurlMentionData, media: TweetMediaItem[]) {
604
+ if (media.length > 0) {
605
+ return media.length;
606
+ }
607
+ if (tweet.attachments?.media_keys?.length) {
608
+ return tweet.attachments.media_keys.length;
609
+ }
610
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
611
+ return urls.filter(
612
+ (url) =>
613
+ url &&
614
+ typeof url === "object" &&
615
+ typeof (url as Record<string, unknown>).media_key === "string",
616
+ ).length;
617
+ }
618
+
619
+ function getReferencedTweetId(tweet: XurlMentionData, type: string) {
620
+ return (
621
+ tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
622
+ );
623
+ }
624
+
625
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
626
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
627
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
628
+ tweetId,
629
+ text,
630
+ );
631
+ }
632
+
633
+ function findExistingProfileIdForUser(db: Database, user: XurlMentionUser) {
634
+ const username = String(user.username ?? "").replace(/^@/, "");
635
+ const row = db
636
+ .prepare(
637
+ `
638
+ select id
639
+ from profiles
640
+ where id = ? or handle = ?
641
+ limit 1
642
+ `,
643
+ )
644
+ .get(buildExternalProfileId(user.id), username) as
645
+ | { id: string }
646
+ | undefined;
647
+ return row?.id ?? null;
648
+ }
649
+
650
+ function mergeAuthoredPayloadIntoLocalStore({
651
+ db,
652
+ accountId,
653
+ payload,
654
+ sourceUser,
655
+ }: {
656
+ db: Database;
657
+ accountId: string;
658
+ payload: AuthoredPayload;
659
+ sourceUser: XurlMentionUser;
660
+ }) {
661
+ const usersById = new Map(
662
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
663
+ );
664
+ const fallbackUserIds = new Set<string>();
665
+ if (!usersById.has(sourceUser.id)) {
666
+ usersById.set(sourceUser.id, sourceUser);
667
+ fallbackUserIds.add(sourceUser.id);
668
+ }
669
+ const mediaByKey = new Map(
670
+ (payload.includes?.media ?? []).map((media) => [media.media_key, media]),
671
+ );
672
+ const upsertTweet = db.prepare(
673
+ `
674
+ insert into tweets (
675
+ id, account_id, author_profile_id, kind, text, created_at,
676
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
677
+ entities_json, media_json, quoted_tweet_id
678
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
679
+ on conflict(id) do update set
680
+ account_id = tweets.account_id,
681
+ author_profile_id = excluded.author_profile_id,
682
+ kind = case
683
+ when tweets.kind in ('home', 'mention', 'authored') then tweets.kind
684
+ when excluded.kind in ('home', 'mention', 'authored') then excluded.kind
685
+ else coalesce(nullif(tweets.kind, ''), excluded.kind)
686
+ end,
687
+ text = excluded.text,
688
+ created_at = excluded.created_at,
689
+ like_count = excluded.like_count,
690
+ media_count = excluded.media_count,
691
+ entities_json = excluded.entities_json,
692
+ media_json = excluded.media_json,
693
+ is_replied = max(tweets.is_replied, excluded.is_replied),
694
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
695
+ quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
696
+ bookmarked = tweets.bookmarked,
697
+ liked = tweets.liked
698
+ `,
699
+ );
700
+
701
+ const writeTweet = (tweet: XurlMentionData, kind: "authored" | "thread") => {
702
+ const author =
703
+ usersById.get(tweet.author_id) ??
704
+ ({
705
+ id: tweet.author_id,
706
+ username: `user_${tweet.author_id}`,
707
+ name: `user_${tweet.author_id}`,
708
+ } as const);
709
+ const profileId =
710
+ usersById.has(tweet.author_id) && !fallbackUserIds.has(tweet.author_id)
711
+ ? upsertProfileFromXUser(db, author).profile.id
712
+ : ((fallbackUserIds.has(tweet.author_id)
713
+ ? findExistingProfileIdForUser(db, author)
714
+ : null) ??
715
+ ensureStubProfileForXUser(db, tweet.author_id).profile.id);
716
+ const replyToId = getReferencedTweetId(tweet, "replied_to");
717
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted");
718
+ const media = toLocalMedia(tweet, mediaByKey);
719
+ upsertTweet.run(
720
+ tweet.id,
721
+ accountId,
722
+ profileId,
723
+ kind,
724
+ tweet.text,
725
+ tweet.created_at,
726
+ replyToId ? 1 : 0,
727
+ replyToId,
728
+ Number(tweet.public_metrics?.like_count ?? 0),
729
+ getMediaCount(tweet, media),
730
+ 0,
731
+ 0,
732
+ JSON.stringify(toLocalEntities(tweet)),
733
+ JSON.stringify(media),
734
+ quotedTweetId,
735
+ );
736
+ replaceTweetFts(db, tweet.id, tweet.text);
737
+ };
738
+
739
+ db.transaction(() => {
740
+ const seenAt = new Date().toISOString();
741
+ for (const includedTweet of payload.includes?.tweets ?? []) {
742
+ writeTweet(includedTweet, "thread");
743
+ }
744
+ for (const tweet of payload.data) {
745
+ writeTweet(tweet, "authored");
746
+ upsertTweetAccountEdge(db, {
747
+ accountId,
748
+ tweetId: tweet.id,
749
+ kind: "authored",
750
+ source: "xurl",
751
+ seenAt,
752
+ rawJson: JSON.stringify(tweet),
753
+ });
754
+ }
755
+ })();
756
+ }
757
+
758
+ function appendUniqueById<T extends { id: string }>(
759
+ target: T[],
760
+ seenIds: Set<string>,
761
+ items: T[] | undefined,
762
+ ) {
763
+ for (const item of items ?? []) {
764
+ if (seenIds.has(item.id)) {
765
+ continue;
766
+ }
767
+ seenIds.add(item.id);
768
+ target.push(item);
769
+ }
770
+ }
771
+
772
+ function appendUniqueMedia(
773
+ target: XurlMedia[],
774
+ seenIds: Set<string>,
775
+ items: XurlMedia[] | undefined,
776
+ ) {
777
+ for (const item of items ?? []) {
778
+ if (seenIds.has(item.media_key)) {
779
+ continue;
780
+ }
781
+ seenIds.add(item.media_key);
782
+ target.push(item);
783
+ }
784
+ }
785
+
786
+ function mergePages({
787
+ pages,
788
+ userId,
789
+ nextToken,
790
+ }: {
791
+ pages: XurlUserTweetsResponse[];
792
+ userId: string;
793
+ nextToken: string | null;
794
+ }): AuthoredPayload {
795
+ const tweets: XurlMentionData[] = [];
796
+ const users: XurlMentionUser[] = [];
797
+ const includedTweets: XurlTweetData[] = [];
798
+ const media: XurlMedia[] = [];
799
+ const seenTweetIds = new Set<string>();
800
+ const seenUserIds = new Set<string>();
801
+ const seenIncludedTweetIds = new Set<string>();
802
+ const seenMediaKeys = new Set<string>();
803
+
804
+ for (const page of pages) {
805
+ for (const tweet of page.items) {
806
+ const normalized = toMentionData(tweet, userId);
807
+ if (seenTweetIds.has(normalized.id)) {
808
+ continue;
809
+ }
810
+ seenTweetIds.add(normalized.id);
811
+ tweets.push(normalized);
812
+ }
813
+ appendUniqueById(users, seenUserIds, page.includes?.users);
814
+ appendUniqueById(
815
+ includedTweets,
816
+ seenIncludedTweetIds,
817
+ page.includes?.tweets,
818
+ );
819
+ appendUniqueMedia(media, seenMediaKeys, page.includes?.media);
820
+ }
821
+
822
+ const newestId = getNewestTweetId(tweets);
823
+ const oldestId = getOldestTweetId(tweets);
824
+ const includes = {
825
+ ...(users.length > 0 ? { users } : {}),
826
+ ...(includedTweets.length > 0 ? { tweets: includedTweets } : {}),
827
+ ...(media.length > 0 ? { media } : {}),
828
+ };
829
+
830
+ return {
831
+ data: tweets,
832
+ ...(Object.keys(includes).length > 0 ? { includes } : {}),
833
+ meta: {
834
+ result_count: tweets.length,
835
+ page_count: pages.length,
836
+ next_token: nextToken,
837
+ ...(newestId ? { newest_id: newestId } : {}),
838
+ ...(oldestId ? { oldest_id: oldestId } : {}),
839
+ },
840
+ };
841
+ }
842
+
843
+ function formatError(error: unknown) {
844
+ return error instanceof Error ? error.message : String(error);
845
+ }
846
+
847
+ function buildResult({
848
+ accountId,
849
+ userId,
850
+ effectiveSinceId,
851
+ nextSinceId,
852
+ nextToken,
853
+ pageCount,
854
+ payload,
855
+ partial,
856
+ error,
857
+ }: {
858
+ accountId: string;
859
+ userId: string;
860
+ effectiveSinceId: string | null;
861
+ nextSinceId: string | null;
862
+ nextToken: string | null;
863
+ pageCount: number;
864
+ payload: AuthoredPayload;
865
+ partial: boolean;
866
+ error?: string;
867
+ }) {
868
+ return {
869
+ ok: !partial,
870
+ kind: "authored" as const,
871
+ source: "xurl" as const,
872
+ accountId,
873
+ userId,
874
+ count: payload.data.length,
875
+ pages: pageCount,
876
+ sinceId: effectiveSinceId,
877
+ nextSinceId,
878
+ nextToken,
879
+ partial,
880
+ ...(error ? { error } : {}),
881
+ cursor: {
882
+ sinceId: nextSinceId,
883
+ paginationToken: nextToken,
884
+ pending: Boolean(nextToken),
885
+ },
886
+ payload,
887
+ };
888
+ }
889
+
890
+ export async function syncAuthoredTweets({
891
+ account,
892
+ mode = "xurl",
893
+ limit = DEFAULT_LIMIT,
894
+ maxPages,
895
+ sinceId,
896
+ untilId,
897
+ }: {
898
+ account?: string;
899
+ mode?: AuthoredSyncMode;
900
+ limit?: number;
901
+ maxPages?: number;
902
+ sinceId?: string;
903
+ untilId?: string;
904
+ }) {
905
+ if (mode !== "xurl") {
906
+ throw new Error("authored sync only supports --mode xurl");
907
+ }
908
+
909
+ const pageLimit = assertXurlLimit(limit);
910
+ const parsedMaxPages = parseMaxPages(maxPages);
911
+ const db = getNativeDb();
912
+ const identity = await resolveAuthoredIdentity({ account, db });
913
+ const cursor = readAuthoredCursor(db, identity.accountId);
914
+ const usePersistedForward =
915
+ sinceId === undefined && !untilId && cursor.state === "pending-forward";
916
+ const usePersistedUntil =
917
+ sinceId === undefined &&
918
+ Boolean(untilId) &&
919
+ cursor.state === "pending-until" &&
920
+ cursor.untilId === untilId;
921
+ const shouldSeedFromArchive =
922
+ !usePersistedForward &&
923
+ !cursor.sinceId &&
924
+ sinceId === undefined &&
925
+ !untilId;
926
+ const archiveSinceSeed = shouldSeedFromArchive
927
+ ? findArchiveAuthoredSinceSeed(db, identity.accountId)
928
+ : null;
929
+ if (shouldSeedFromArchive && !archiveSinceSeed) {
930
+ console.error(
931
+ "birdclaw sync authored: no archive baseline found; starting a full backwards scan",
932
+ );
933
+ }
934
+ const persistedUntilSinceId: string | null = usePersistedUntil
935
+ ? (("requestedSinceId" in cursor
936
+ ? cursor.requestedSinceId
937
+ : cursor.sinceId) ?? null)
938
+ : null;
939
+ const effectiveSinceId: string | null =
940
+ sinceId ??
941
+ archiveSinceSeed ??
942
+ (untilId ? persistedUntilSinceId : cursor.sinceId) ??
943
+ null;
944
+ let nextToken = usePersistedForward
945
+ ? cursor.token
946
+ : usePersistedUntil
947
+ ? cursor.token
948
+ : undefined;
949
+ let newestSeenId = usePersistedForward
950
+ ? maxTweetId(cursor.sinceId, cursor.pendingNewestId)
951
+ : cursor.sinceId;
952
+ const pages: XurlUserTweetsResponse[] = [];
953
+ const sourceUser = toFallbackUser({
954
+ userId: identity.userId,
955
+ username: identity.username,
956
+ authenticatedUser: identity.authenticatedUser,
957
+ });
958
+ let pageCount = 0;
959
+
960
+ while (parsedMaxPages === null || pageCount < parsedMaxPages) {
961
+ let page: XurlUserTweetsResponse;
962
+ try {
963
+ page = await listUserTweets(identity.userId, {
964
+ maxResults: pageLimit,
965
+ paginationToken: nextToken,
966
+ excludeRetweets: false,
967
+ sinceId: effectiveSinceId ?? undefined,
968
+ untilId,
969
+ tweetFields: AUTHORED_TWEET_FIELDS,
970
+ expansions: AUTHORED_EXPANSIONS,
971
+ userFields: AUTHORED_USER_FIELDS,
972
+ mediaFields: AUTHORED_MEDIA_FIELDS,
973
+ auth: "oauth2",
974
+ });
975
+ } catch (error) {
976
+ if (pages.length === 0) {
977
+ throw error;
978
+ }
979
+ const payload = mergePages({
980
+ pages,
981
+ userId: identity.userId,
982
+ nextToken: nextToken ?? null,
983
+ });
984
+ return buildResult({
985
+ accountId: identity.accountId,
986
+ userId: identity.userId,
987
+ effectiveSinceId,
988
+ nextSinceId: untilId ? cursor.sinceId : effectiveSinceId,
989
+ nextToken: nextToken ?? null,
990
+ pageCount,
991
+ payload,
992
+ partial: true,
993
+ error: formatError(error),
994
+ });
995
+ }
996
+
997
+ const pagePayload = mergePages({
998
+ pages: [page],
999
+ userId: identity.userId,
1000
+ nextToken: page.nextToken,
1001
+ });
1002
+ mergeAuthoredPayloadIntoLocalStore({
1003
+ db,
1004
+ accountId: identity.accountId,
1005
+ payload: pagePayload,
1006
+ sourceUser,
1007
+ });
1008
+ pages.push(page);
1009
+ pageCount += 1;
1010
+ newestSeenId = maxTweetId(newestSeenId, pagePayload.meta.newest_id);
1011
+ nextToken = page.nextToken ?? undefined;
1012
+
1013
+ if (nextToken && untilId) {
1014
+ writePendingUntilCursor(db, identity.accountId, {
1015
+ sinceId: cursor.sinceId,
1016
+ token: nextToken,
1017
+ untilId,
1018
+ requestedSinceId: effectiveSinceId,
1019
+ });
1020
+ } else if (nextToken) {
1021
+ writePendingForwardCursor(db, identity.accountId, {
1022
+ sinceId: effectiveSinceId,
1023
+ token: nextToken,
1024
+ pendingNewestId: newestSeenId,
1025
+ });
1026
+ }
1027
+
1028
+ if (!nextToken) {
1029
+ break;
1030
+ }
1031
+ }
1032
+
1033
+ const capped = Boolean(nextToken);
1034
+ const nextSinceId = untilId
1035
+ ? cursor.sinceId
1036
+ : capped
1037
+ ? effectiveSinceId
1038
+ : maxTweetId(newestSeenId, effectiveSinceId, cursor.sinceId);
1039
+ if (untilId && capped && nextToken) {
1040
+ writePendingUntilCursor(db, identity.accountId, {
1041
+ sinceId: cursor.sinceId,
1042
+ token: nextToken,
1043
+ untilId,
1044
+ requestedSinceId: effectiveSinceId,
1045
+ });
1046
+ } else if (untilId) {
1047
+ writeCommittedCursor(db, identity.accountId, cursor.sinceId);
1048
+ } else if (capped && nextToken) {
1049
+ writePendingForwardCursor(db, identity.accountId, {
1050
+ sinceId: nextSinceId,
1051
+ token: nextToken,
1052
+ pendingNewestId: newestSeenId,
1053
+ });
1054
+ } else {
1055
+ writeCommittedCursor(db, identity.accountId, nextSinceId);
1056
+ }
1057
+
1058
+ const payload = mergePages({
1059
+ pages,
1060
+ userId: identity.userId,
1061
+ nextToken: nextToken ?? null,
1062
+ });
1063
+ return buildResult({
1064
+ accountId: identity.accountId,
1065
+ userId: identity.userId,
1066
+ effectiveSinceId,
1067
+ nextSinceId,
1068
+ nextToken: nextToken ?? null,
1069
+ pageCount,
1070
+ payload,
1071
+ partial: capped,
1072
+ ...(capped ? { error: "max pages reached before sync completed" } : {}),
1073
+ });
1074
+ }