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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- 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
|
+
};
|