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,736 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ import { getNativeDb } from "./db";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const ARCHIVE_JSON_PAYLOAD = /=\s*(\[[\s\S]*\]|\{[\s\S]*\})/s;
8
+
9
+ interface ArchiveAccountPayload {
10
+ accountId: string;
11
+ username: string;
12
+ displayName: string;
13
+ createdAt: string;
14
+ bio: string;
15
+ }
16
+
17
+ interface ImportedArchiveSummary {
18
+ ok: true;
19
+ archivePath: string;
20
+ account: {
21
+ id: string;
22
+ handle: string;
23
+ displayName: string;
24
+ };
25
+ counts: {
26
+ tweets: number;
27
+ likes: number;
28
+ dmConversations: number;
29
+ dmMessages: number;
30
+ profiles: number;
31
+ };
32
+ }
33
+
34
+ type ArchiveRecord = Record<string, unknown>;
35
+
36
+ function normalizeArchivePath(value: string) {
37
+ return value.replaceAll("\\", "/");
38
+ }
39
+
40
+ function extractArchiveJson(content: string): unknown {
41
+ const match = ARCHIVE_JSON_PAYLOAD.exec(content);
42
+ if (!match) {
43
+ return [];
44
+ }
45
+
46
+ return JSON.parse(match[1]);
47
+ }
48
+
49
+ function parseArchiveArray(content: string): ArchiveRecord[] {
50
+ const parsed = extractArchiveJson(content);
51
+ return Array.isArray(parsed)
52
+ ? parsed.filter((item): item is ArchiveRecord => Boolean(item))
53
+ : [];
54
+ }
55
+
56
+ async function runUnzip(
57
+ _archivePath: string,
58
+ args: string[],
59
+ maxBuffer = 1024 * 1024 * 256,
60
+ ) {
61
+ const { stdout } = await execFileAsync("unzip", args, {
62
+ maxBuffer,
63
+ });
64
+ return stdout;
65
+ }
66
+
67
+ async function listArchiveEntries(archivePath: string) {
68
+ const stdout = await runUnzip(
69
+ archivePath,
70
+ ["-Z1", archivePath],
71
+ 1024 * 1024 * 64,
72
+ );
73
+ return stdout
74
+ .split("\n")
75
+ .map((item) => item.trim())
76
+ .filter((item) => item.length > 0);
77
+ }
78
+
79
+ async function readArchiveEntry(archivePath: string, entryPath: string) {
80
+ return runUnzip(archivePath, ["-p", archivePath, entryPath]);
81
+ }
82
+
83
+ function getFirstEntry(entries: string[], pattern: RegExp) {
84
+ return entries.find((entry) => pattern.test(normalizeArchivePath(entry)));
85
+ }
86
+
87
+ function getMatchingEntries(entries: string[], pattern: RegExp) {
88
+ return entries.filter((entry) => pattern.test(normalizeArchivePath(entry)));
89
+ }
90
+
91
+ function parseTwitterDate(value: unknown) {
92
+ if (typeof value !== "string" || value.length === 0) {
93
+ return new Date(0).toISOString();
94
+ }
95
+
96
+ const parsed = new Date(value);
97
+ return Number.isNaN(parsed.getTime())
98
+ ? new Date(0).toISOString()
99
+ : parsed.toISOString();
100
+ }
101
+
102
+ function asRecord(value: unknown): Record<string, unknown> | null {
103
+ return value && typeof value === "object" && !Array.isArray(value)
104
+ ? (value as Record<string, unknown>)
105
+ : null;
106
+ }
107
+
108
+ function asArray<T>(value: unknown): T[] {
109
+ return Array.isArray(value) ? (value as T[]) : [];
110
+ }
111
+
112
+ function toInt(value: unknown) {
113
+ const parsed = Number(value);
114
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
115
+ }
116
+
117
+ function getTweetMediaCount(tweet: Record<string, unknown>) {
118
+ const entities = asRecord(tweet.entities);
119
+ const extendedEntities = asRecord(tweet.extended_entities);
120
+ const entitiesMedia = asArray(entities?.media);
121
+ const extendedMedia = asArray(extendedEntities?.media);
122
+ return Math.max(entitiesMedia.length, extendedMedia.length);
123
+ }
124
+
125
+ function extractTweetEntities(tweet: Record<string, unknown>) {
126
+ const entities = asRecord(tweet.entities);
127
+ const urls = asArray<Record<string, unknown>>(entities?.urls)
128
+ .map((entry) => ({
129
+ url: String(entry.url ?? ""),
130
+ expandedUrl: String(
131
+ entry.expanded_url ?? entry.expandedUrl ?? entry.url ?? "",
132
+ ),
133
+ displayUrl: String(
134
+ entry.display_url ??
135
+ entry.displayUrl ??
136
+ entry.expanded_url ??
137
+ entry.url ??
138
+ "",
139
+ ),
140
+ start: Number(asArray<number>(entry.indices)[0] ?? 0),
141
+ end: Number(asArray<number>(entry.indices)[1] ?? 0),
142
+ title: typeof entry.title === "string" ? entry.title : undefined,
143
+ description:
144
+ typeof entry.description === "string" ? entry.description : null,
145
+ }))
146
+ .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0);
147
+ const mentions = asArray<Record<string, unknown>>(entities?.user_mentions)
148
+ .map((entry) => ({
149
+ username: String(entry.screen_name ?? ""),
150
+ id: String(entry.id_str ?? entry.id ?? ""),
151
+ start: Number(asArray<number>(entry.indices)[0] ?? 0),
152
+ end: Number(asArray<number>(entry.indices)[1] ?? 0),
153
+ }))
154
+ .filter((entry) => entry.username.length > 0);
155
+ const hashtags = asArray<Record<string, unknown>>(entities?.hashtags)
156
+ .map((entry) => ({
157
+ tag: String(entry.text ?? ""),
158
+ start: Number(asArray<number>(entry.indices)[0] ?? 0),
159
+ end: Number(asArray<number>(entry.indices)[1] ?? 0),
160
+ }))
161
+ .filter((entry) => entry.tag.length > 0);
162
+
163
+ return {
164
+ ...(urls.length > 0 ? { urls } : {}),
165
+ ...(mentions.length > 0 ? { mentions } : {}),
166
+ ...(hashtags.length > 0 ? { hashtags } : {}),
167
+ };
168
+ }
169
+
170
+ function extractTweetMedia(tweet: Record<string, unknown>) {
171
+ const extendedEntities = asRecord(tweet.extended_entities);
172
+ const entities = asRecord(tweet.entities);
173
+ const sourceMedia = [
174
+ ...asArray<Record<string, unknown>>(extendedEntities?.media),
175
+ ...asArray<Record<string, unknown>>(entities?.media),
176
+ ];
177
+ const seen = new Set<string>();
178
+
179
+ return sourceMedia
180
+ .map((entry) => {
181
+ const url = String(
182
+ entry.media_url_https ?? entry.media_url ?? entry.url ?? "",
183
+ );
184
+ const thumbnailUrl = String(
185
+ entry.media_url_https ?? entry.media_url ?? url,
186
+ );
187
+ const type = String(entry.type ?? "image");
188
+ return {
189
+ url,
190
+ type:
191
+ type === "photo"
192
+ ? "image"
193
+ : type === "video" || type === "animated_gif"
194
+ ? type === "animated_gif"
195
+ ? "gif"
196
+ : "video"
197
+ : "unknown",
198
+ altText:
199
+ typeof entry.ext_alt_text === "string"
200
+ ? entry.ext_alt_text
201
+ : undefined,
202
+ thumbnailUrl,
203
+ };
204
+ })
205
+ .filter((entry) => {
206
+ if (!entry.url || seen.has(entry.url)) {
207
+ return false;
208
+ }
209
+ seen.add(entry.url);
210
+ return true;
211
+ });
212
+ }
213
+
214
+ function buildAccountPayload(
215
+ accountRecord: Record<string, unknown> | null,
216
+ profileRecord: Record<string, unknown> | null,
217
+ ): ArchiveAccountPayload {
218
+ const account = asRecord(accountRecord?.account);
219
+ const profile = asRecord(profileRecord?.profile);
220
+ const description = asRecord(profile?.description);
221
+
222
+ return {
223
+ accountId: String(account?.accountId ?? "unknown"),
224
+ username: String(account?.username ?? "unknown"),
225
+ displayName: String(
226
+ account?.accountDisplayName ??
227
+ account?.name ??
228
+ account?.username ??
229
+ "Unknown",
230
+ ),
231
+ createdAt: parseTwitterDate(account?.createdAt),
232
+ bio: String(description?.bio ?? ""),
233
+ };
234
+ }
235
+
236
+ function inferProfileFromDirectory(
237
+ userId: string,
238
+ directory: Map<string, { handle?: string; displayName?: string }>,
239
+ ) {
240
+ const match = directory.get(userId);
241
+ const handle = match?.handle?.replace(/^@/, "") || `id${userId}`;
242
+ const displayName = match?.displayName || handle;
243
+ return { handle, displayName };
244
+ }
245
+
246
+ function clearImportedData() {
247
+ const db = getNativeDb();
248
+ db.exec(`
249
+ delete from ai_scores;
250
+ delete from tweet_actions;
251
+ delete from dm_fts;
252
+ delete from tweets_fts;
253
+ delete from dm_messages;
254
+ delete from dm_conversations;
255
+ delete from tweets;
256
+ delete from profiles;
257
+ delete from accounts;
258
+ `);
259
+ }
260
+
261
+ export async function importArchive(
262
+ archivePath: string,
263
+ ): Promise<ImportedArchiveSummary> {
264
+ const entries = await listArchiveEntries(archivePath);
265
+ const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
266
+ const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
267
+ const tweetEntries = getMatchingEntries(
268
+ entries,
269
+ /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
270
+ );
271
+ const noteTweetEntries = getMatchingEntries(
272
+ entries,
273
+ /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i,
274
+ );
275
+ const likeEntries = getMatchingEntries(
276
+ entries,
277
+ /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
278
+ );
279
+ const dmEntries = getMatchingEntries(
280
+ entries,
281
+ /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
282
+ );
283
+
284
+ if (!accountEntry) {
285
+ throw new Error("Archive missing data/account.js");
286
+ }
287
+
288
+ const [accountContent, profileContent] = await Promise.all([
289
+ readArchiveEntry(archivePath, accountEntry),
290
+ profileEntry
291
+ ? readArchiveEntry(archivePath, profileEntry)
292
+ : Promise.resolve("[]"),
293
+ ]);
294
+
295
+ const accountPayload = buildAccountPayload(
296
+ parseArchiveArray(accountContent)[0] ?? null,
297
+ parseArchiveArray(profileContent)[0] ?? null,
298
+ );
299
+
300
+ const mentionDirectory = new Map<
301
+ string,
302
+ { handle?: string; displayName?: string }
303
+ >();
304
+ const tweetRows: Array<{
305
+ id: string;
306
+ text: string;
307
+ createdAt: string;
308
+ isReplied: number;
309
+ replyToId: string | null;
310
+ likeCount: number;
311
+ mediaCount: number;
312
+ entitiesJson: string;
313
+ mediaJson: string;
314
+ quotedTweetId: string | null;
315
+ }> = [];
316
+
317
+ for (const entry of tweetEntries) {
318
+ const content = await readArchiveEntry(archivePath, entry);
319
+ for (const wrapper of parseArchiveArray(content)) {
320
+ const tweet = asRecord(wrapper.tweet);
321
+ if (!tweet) continue;
322
+
323
+ for (const mention of asArray<Record<string, unknown>>(
324
+ asRecord(tweet.entities)?.user_mentions,
325
+ )) {
326
+ const mentionId = String(mention.id_str ?? mention.id ?? "");
327
+ if (!mentionId) continue;
328
+ mentionDirectory.set(mentionId, {
329
+ handle: String(mention.screen_name ?? ""),
330
+ displayName: String(mention.name ?? mention.screen_name ?? mentionId),
331
+ });
332
+ }
333
+
334
+ const replyUserId = String(
335
+ tweet.in_reply_to_user_id_str ?? tweet.in_reply_to_user_id ?? "",
336
+ );
337
+ const replyScreenName = String(tweet.in_reply_to_screen_name ?? "");
338
+ if (replyUserId && replyScreenName) {
339
+ mentionDirectory.set(replyUserId, {
340
+ handle: replyScreenName,
341
+ displayName: replyScreenName,
342
+ });
343
+ }
344
+
345
+ tweetRows.push({
346
+ id: String(tweet.id_str ?? tweet.id),
347
+ text: String(tweet.full_text ?? tweet.text ?? ""),
348
+ createdAt: parseTwitterDate(tweet.created_at),
349
+ isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
350
+ replyToId: tweet.in_reply_to_status_id_str
351
+ ? String(tweet.in_reply_to_status_id_str)
352
+ : null,
353
+ likeCount: toInt(tweet.favorite_count),
354
+ mediaCount: getTweetMediaCount(tweet),
355
+ entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
356
+ mediaJson: JSON.stringify(extractTweetMedia(tweet)),
357
+ quotedTweetId: tweet.quoted_status_id_str
358
+ ? String(tweet.quoted_status_id_str)
359
+ : null,
360
+ });
361
+ }
362
+ }
363
+
364
+ for (const entry of noteTweetEntries) {
365
+ const content = await readArchiveEntry(archivePath, entry);
366
+ for (const wrapper of parseArchiveArray(content)) {
367
+ const noteTweet = asRecord(wrapper.noteTweet);
368
+ if (!noteTweet) continue;
369
+ const core = asRecord(noteTweet.core);
370
+ tweetRows.push({
371
+ id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
372
+ text: String(core?.text ?? ""),
373
+ createdAt: parseTwitterDate(noteTweet.createdAt),
374
+ isReplied: 0,
375
+ replyToId: null,
376
+ likeCount: 0,
377
+ mediaCount: 0,
378
+ entitiesJson: "{}",
379
+ mediaJson: "[]",
380
+ quotedTweetId: null,
381
+ });
382
+ }
383
+ }
384
+
385
+ type MessageRow = {
386
+ id: string;
387
+ conversationId: string;
388
+ senderProfileId: string;
389
+ text: string;
390
+ createdAt: string;
391
+ direction: "inbound" | "outbound";
392
+ mediaCount: number;
393
+ };
394
+
395
+ const profiles = new Map<
396
+ string,
397
+ {
398
+ id: string;
399
+ handle: string;
400
+ displayName: string;
401
+ bio: string;
402
+ followersCount: number;
403
+ avatarHue: number;
404
+ createdAt: string;
405
+ }
406
+ >();
407
+ const conversations = new Map<
408
+ string,
409
+ {
410
+ id: string;
411
+ title: string;
412
+ accountId: string;
413
+ participantProfileId: string;
414
+ lastMessageAt: string;
415
+ unreadCount: number;
416
+ needsReply: number;
417
+ }
418
+ >();
419
+ const dmMessages: MessageRow[] = [];
420
+
421
+ const localProfile = {
422
+ id: "profile_me",
423
+ handle: accountPayload.username,
424
+ displayName: accountPayload.displayName,
425
+ bio: accountPayload.bio,
426
+ followersCount: 0,
427
+ avatarHue: 18,
428
+ createdAt: accountPayload.createdAt,
429
+ };
430
+ profiles.set(localProfile.id, localProfile);
431
+
432
+ for (const entry of dmEntries) {
433
+ const content = await readArchiveEntry(archivePath, entry);
434
+ for (const wrapper of parseArchiveArray(content)) {
435
+ const dmConversation = asRecord(wrapper.dmConversation);
436
+ if (!dmConversation) continue;
437
+
438
+ const conversationId = String(dmConversation.conversationId ?? "");
439
+ if (!conversationId) continue;
440
+
441
+ const conversationName = String(dmConversation.name ?? "").trim();
442
+ const participantIds = new Set<string>();
443
+ const rawMessages = asArray<Record<string, unknown>>(
444
+ dmConversation.messages,
445
+ );
446
+
447
+ for (const event of rawMessages) {
448
+ const messageCreate = asRecord(event.messageCreate);
449
+ if (messageCreate) {
450
+ const senderId = String(messageCreate.senderId ?? "");
451
+ const recipientId = String(messageCreate.recipientId ?? "");
452
+ if (senderId) participantIds.add(senderId);
453
+ if (recipientId) participantIds.add(recipientId);
454
+ }
455
+
456
+ const joinConversation = asRecord(event.joinConversation);
457
+ if (joinConversation) {
458
+ for (const userId of asArray<string>(
459
+ joinConversation.participantsSnapshot,
460
+ )) {
461
+ participantIds.add(String(userId));
462
+ }
463
+ }
464
+
465
+ const participantsJoin = asRecord(event.participantsJoin);
466
+ if (participantsJoin) {
467
+ for (const userId of asArray<string>(participantsJoin.userIds)) {
468
+ participantIds.add(String(userId));
469
+ }
470
+ const initiatingUserId = String(
471
+ participantsJoin.initiatingUserId ?? "",
472
+ );
473
+ if (initiatingUserId) {
474
+ participantIds.add(initiatingUserId);
475
+ }
476
+ }
477
+
478
+ const participantsLeave = asRecord(event.participantsLeave);
479
+ if (participantsLeave) {
480
+ for (const userId of asArray<string>(participantsLeave.userIds)) {
481
+ participantIds.add(String(userId));
482
+ }
483
+ const initiatingUserId = String(
484
+ participantsLeave.initiatingUserId ?? "",
485
+ );
486
+ if (initiatingUserId) {
487
+ participantIds.add(initiatingUserId);
488
+ }
489
+ }
490
+ }
491
+
492
+ const externalParticipantIds = [...participantIds].filter(
493
+ (userId) => userId && userId !== accountPayload.accountId,
494
+ );
495
+ const isGroup =
496
+ conversationName.length > 0 || externalParticipantIds.length > 1;
497
+ const participantProfileId = isGroup
498
+ ? `profile_group_${conversationId}`
499
+ : `profile_user_${externalParticipantIds[0] ?? conversationId}`;
500
+
501
+ if (!profiles.has(participantProfileId)) {
502
+ if (isGroup) {
503
+ profiles.set(participantProfileId, {
504
+ id: participantProfileId,
505
+ handle: `group-${conversationId}`,
506
+ displayName:
507
+ conversationName || `Group DM ${externalParticipantIds.length}`,
508
+ bio: `Group DM with ${externalParticipantIds.length} participants`,
509
+ followersCount: 0,
510
+ avatarHue: 220,
511
+ createdAt: accountPayload.createdAt,
512
+ });
513
+ } else {
514
+ const otherUserId = externalParticipantIds[0] ?? conversationId;
515
+ const inferred = inferProfileFromDirectory(
516
+ otherUserId,
517
+ mentionDirectory,
518
+ );
519
+ profiles.set(participantProfileId, {
520
+ id: participantProfileId,
521
+ handle: inferred.handle,
522
+ displayName: inferred.displayName,
523
+ bio: `Imported from archive user ${otherUserId}`,
524
+ followersCount: 0,
525
+ avatarHue: 210,
526
+ createdAt: accountPayload.createdAt,
527
+ });
528
+ }
529
+ }
530
+
531
+ const messageEvents = rawMessages
532
+ .map((event) => asRecord(event.messageCreate))
533
+ .filter((event): event is Record<string, unknown> => event !== null)
534
+ .map((messageCreate) => {
535
+ const senderId = String(messageCreate.senderId ?? "");
536
+ const senderProfileId =
537
+ senderId === accountPayload.accountId
538
+ ? "profile_me"
539
+ : `profile_user_${senderId}`;
540
+
541
+ if (senderId && senderId !== accountPayload.accountId) {
542
+ const inferred = inferProfileFromDirectory(
543
+ senderId,
544
+ mentionDirectory,
545
+ );
546
+ if (!profiles.has(senderProfileId)) {
547
+ profiles.set(senderProfileId, {
548
+ id: senderProfileId,
549
+ handle: inferred.handle,
550
+ displayName: inferred.displayName,
551
+ bio: `Imported from archive user ${senderId}`,
552
+ followersCount: 0,
553
+ avatarHue: 240,
554
+ createdAt: accountPayload.createdAt,
555
+ });
556
+ }
557
+ }
558
+
559
+ return {
560
+ id: String(messageCreate.id ?? `${conversationId}-${senderId}`),
561
+ conversationId,
562
+ senderProfileId,
563
+ text: String(messageCreate.text ?? ""),
564
+ createdAt: parseTwitterDate(messageCreate.createdAt),
565
+ direction:
566
+ senderId === accountPayload.accountId ? "outbound" : "inbound",
567
+ mediaCount: asArray(messageCreate.mediaUrls).length,
568
+ } satisfies MessageRow;
569
+ })
570
+ .sort(
571
+ (left, right) =>
572
+ new Date(left.createdAt).getTime() -
573
+ new Date(right.createdAt).getTime(),
574
+ );
575
+
576
+ if (messageEvents.length === 0) {
577
+ continue;
578
+ }
579
+
580
+ const lastMessage = messageEvents.at(-1);
581
+ if (!lastMessage) continue;
582
+
583
+ dmMessages.push(...messageEvents);
584
+ conversations.set(conversationId, {
585
+ id: conversationId,
586
+ title:
587
+ profiles.get(participantProfileId)?.displayName ||
588
+ conversationName ||
589
+ conversationId,
590
+ accountId: "acct_primary",
591
+ participantProfileId,
592
+ lastMessageAt: lastMessage.createdAt,
593
+ unreadCount: 0,
594
+ needsReply: lastMessage.direction === "inbound" ? 1 : 0,
595
+ });
596
+ }
597
+ }
598
+
599
+ const likeCount = likeEntries.reduce(async (countPromise, entry) => {
600
+ const count = await countPromise;
601
+ const content = await readArchiveEntry(archivePath, entry);
602
+ return count + parseArchiveArray(content).length;
603
+ }, Promise.resolve(0));
604
+
605
+ clearImportedData();
606
+
607
+ const db = getNativeDb();
608
+ const insertAccount = db.prepare(`
609
+ insert into accounts (id, name, handle, transport, is_default, created_at)
610
+ values (?, ?, ?, ?, 1, ?)
611
+ `);
612
+ const insertProfile = db.prepare(`
613
+ insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
614
+ values (?, ?, ?, ?, ?, ?, ?, ?)
615
+ `);
616
+ const insertTweet = db.prepare(`
617
+ insert into tweets (
618
+ id, account_id, author_profile_id, kind, text, created_at, is_replied,
619
+ reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
620
+ ) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
621
+ `);
622
+ const insertTweetFts = db.prepare(
623
+ "insert into tweets_fts (tweet_id, text) values (?, ?)",
624
+ );
625
+ const insertConversation = db.prepare(`
626
+ insert into dm_conversations (
627
+ id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
628
+ ) values (?, ?, ?, ?, ?, ?, ?)
629
+ `);
630
+ const insertMessage = db.prepare(`
631
+ insert into dm_messages (
632
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
633
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
634
+ `);
635
+ const insertDmFts = db.prepare(
636
+ "insert into dm_fts (message_id, text) values (?, ?)",
637
+ );
638
+
639
+ db.transaction(() => {
640
+ insertAccount.run(
641
+ "acct_primary",
642
+ accountPayload.displayName,
643
+ `@${accountPayload.username}`,
644
+ "archive",
645
+ accountPayload.createdAt,
646
+ );
647
+
648
+ for (const profile of profiles.values()) {
649
+ insertProfile.run(
650
+ profile.id,
651
+ profile.handle,
652
+ profile.displayName,
653
+ profile.bio,
654
+ profile.followersCount,
655
+ profile.avatarHue,
656
+ null,
657
+ profile.createdAt,
658
+ );
659
+ }
660
+
661
+ for (const tweet of tweetRows) {
662
+ insertTweet.run(
663
+ tweet.id,
664
+ "acct_primary",
665
+ "profile_me",
666
+ tweet.text,
667
+ tweet.createdAt,
668
+ tweet.isReplied,
669
+ tweet.replyToId,
670
+ tweet.likeCount,
671
+ tweet.mediaCount,
672
+ tweet.entitiesJson,
673
+ tweet.mediaJson,
674
+ tweet.quotedTweetId,
675
+ );
676
+ insertTweetFts.run(tweet.id, tweet.text);
677
+ }
678
+
679
+ for (const conversation of conversations.values()) {
680
+ insertConversation.run(
681
+ conversation.id,
682
+ conversation.accountId,
683
+ conversation.participantProfileId,
684
+ conversation.title,
685
+ conversation.lastMessageAt,
686
+ conversation.unreadCount,
687
+ conversation.needsReply,
688
+ );
689
+ }
690
+
691
+ for (const message of dmMessages) {
692
+ insertMessage.run(
693
+ message.id,
694
+ message.conversationId,
695
+ message.senderProfileId,
696
+ message.text,
697
+ message.createdAt,
698
+ message.direction,
699
+ message.direction === "outbound" ? 1 : 0,
700
+ message.mediaCount,
701
+ );
702
+ insertDmFts.run(message.id, message.text);
703
+ }
704
+ })();
705
+
706
+ return {
707
+ ok: true,
708
+ archivePath,
709
+ account: {
710
+ id: accountPayload.accountId,
711
+ handle: accountPayload.username,
712
+ displayName: accountPayload.displayName,
713
+ },
714
+ counts: {
715
+ tweets: tweetRows.length,
716
+ likes: await likeCount,
717
+ dmConversations: conversations.size,
718
+ dmMessages: dmMessages.length,
719
+ profiles: profiles.size,
720
+ },
721
+ };
722
+ }
723
+
724
+ export const __test__ = {
725
+ extractArchiveJson,
726
+ parseArchiveArray,
727
+ parseTwitterDate,
728
+ asRecord,
729
+ asArray,
730
+ toInt,
731
+ getTweetMediaCount,
732
+ extractTweetEntities,
733
+ extractTweetMedia,
734
+ buildAccountPayload,
735
+ inferProfileFromDirectory,
736
+ };