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
@@ -0,0 +1,369 @@
1
+ import type { Database } from "./sqlite";
2
+ import { fetchProfileAffiliations } from "./profile-affiliations";
3
+ import { fetchProfileBioEntities } from "./profile-bio-entities";
4
+ import { fetchProfileSnapshots } from "./profile-history";
5
+ import type { ProfileRecord } from "./types";
6
+
7
+ interface IdentityIndexEntry {
8
+ profileId: string;
9
+ kind: string;
10
+ value: string;
11
+ source: string;
12
+ weight: number;
13
+ }
14
+
15
+ const KIND_WEIGHTS: Record<string, number> = {
16
+ affiliation: 90,
17
+ bio_handle: 75,
18
+ bio_company: 65,
19
+ profile_history: 55,
20
+ profile_handle: 45,
21
+ profile_name: 35,
22
+ profile_bio: 35,
23
+ profile_location: 20,
24
+ profile_verified_type: 15,
25
+ profile_bio_url: 12,
26
+ profile_url: 10,
27
+ bio_domain: 8,
28
+ };
29
+
30
+ function normalizeIndexValue(value: string) {
31
+ return value.trim().toLowerCase();
32
+ }
33
+
34
+ function addEntry(
35
+ entries: Map<string, IdentityIndexEntry>,
36
+ entry: Omit<IdentityIndexEntry, "weight"> & { weight?: number },
37
+ ) {
38
+ const value = entry.value.trim();
39
+ if (!value) {
40
+ return;
41
+ }
42
+ const key = `${entry.profileId}:${entry.kind}:${entry.source}:${value.toLowerCase()}`;
43
+ if (!entries.has(key)) {
44
+ entries.set(key, {
45
+ ...entry,
46
+ value,
47
+ weight: entry.weight ?? KIND_WEIGHTS[entry.kind] ?? 10,
48
+ });
49
+ }
50
+ }
51
+
52
+ function parseJsonObject(value: unknown) {
53
+ if (typeof value !== "string" || value.length === 0) {
54
+ return undefined;
55
+ }
56
+ try {
57
+ const parsed = JSON.parse(value) as unknown;
58
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
59
+ ? (parsed as Record<string, unknown>)
60
+ : undefined;
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ function getUrlEntityExpandedUrl(entity: unknown) {
67
+ if (!entity || typeof entity !== "object") {
68
+ return undefined;
69
+ }
70
+ const record = entity as Record<string, unknown>;
71
+ const expanded = record.expandedUrl ?? record.expanded_url ?? record.url;
72
+ return typeof expanded === "string" && expanded.length > 0
73
+ ? expanded
74
+ : undefined;
75
+ }
76
+
77
+ function getProfileBioUrls(profile: ProfileRecord) {
78
+ const description = profile.entities?.description;
79
+ if (!description || typeof description !== "object") {
80
+ return [];
81
+ }
82
+ const urls = (description as { urls?: unknown }).urls;
83
+ if (!Array.isArray(urls)) {
84
+ return [];
85
+ }
86
+ return urls
87
+ .map(getUrlEntityExpandedUrl)
88
+ .filter((url): url is string => Boolean(url));
89
+ }
90
+
91
+ function profileFromRow(row: Record<string, unknown>): ProfileRecord {
92
+ const entities = parseJsonObject(row.entities_json);
93
+ return {
94
+ id: String(row.id),
95
+ handle: String(row.handle),
96
+ displayName: String(row.display_name),
97
+ bio: String(row.bio ?? ""),
98
+ followersCount: Number(row.followers_count ?? 0),
99
+ followingCount: Number(row.following_count ?? 0),
100
+ avatarHue: Number(row.avatar_hue ?? 0),
101
+ ...(typeof row.avatar_url === "string" && row.avatar_url.length > 0
102
+ ? { avatarUrl: row.avatar_url }
103
+ : {}),
104
+ ...(typeof row.location === "string" && row.location.length > 0
105
+ ? { location: row.location }
106
+ : {}),
107
+ ...(typeof row.url === "string" && row.url.length > 0
108
+ ? { url: row.url }
109
+ : {}),
110
+ ...(typeof row.verified_type === "string" && row.verified_type.length > 0
111
+ ? { verifiedType: row.verified_type }
112
+ : {}),
113
+ ...(entities ? { entities } : {}),
114
+ createdAt: String(row.created_at),
115
+ };
116
+ }
117
+
118
+ function collectProfileEntries(
119
+ profile: ProfileRecord,
120
+ ): Map<string, IdentityIndexEntry> {
121
+ const entries = new Map<string, IdentityIndexEntry>();
122
+ addEntry(entries, {
123
+ profileId: profile.id,
124
+ kind: "profile_handle",
125
+ value: profile.handle,
126
+ source: "profile",
127
+ });
128
+ addEntry(entries, {
129
+ profileId: profile.id,
130
+ kind: "profile_name",
131
+ value: profile.displayName,
132
+ source: "profile",
133
+ });
134
+ addEntry(entries, {
135
+ profileId: profile.id,
136
+ kind: "profile_bio",
137
+ value: profile.bio,
138
+ source: "profile",
139
+ });
140
+ if (profile.location) {
141
+ addEntry(entries, {
142
+ profileId: profile.id,
143
+ kind: "profile_location",
144
+ value: profile.location,
145
+ source: "profile",
146
+ });
147
+ }
148
+ if (profile.url) {
149
+ addEntry(entries, {
150
+ profileId: profile.id,
151
+ kind: "profile_url",
152
+ value: profile.url,
153
+ source: "profile",
154
+ });
155
+ }
156
+ if (profile.verifiedType) {
157
+ addEntry(entries, {
158
+ profileId: profile.id,
159
+ kind: "profile_verified_type",
160
+ value: profile.verifiedType,
161
+ source: "profile",
162
+ });
163
+ }
164
+ for (const url of getProfileBioUrls(profile)) {
165
+ addEntry(entries, {
166
+ profileId: profile.id,
167
+ kind: "profile_bio_url",
168
+ value: url,
169
+ source: "profile",
170
+ });
171
+ }
172
+ return entries;
173
+ }
174
+
175
+ function addAffiliationEntries(
176
+ entries: Map<string, IdentityIndexEntry>,
177
+ profileId: string,
178
+ values: Array<string | null | undefined>,
179
+ source = "affiliation",
180
+ ) {
181
+ for (const value of values) {
182
+ if (!value) {
183
+ continue;
184
+ }
185
+ addEntry(entries, {
186
+ profileId,
187
+ kind: "affiliation",
188
+ value,
189
+ source,
190
+ });
191
+ }
192
+ }
193
+
194
+ export function syncIdentitySearchIndexForProfileIds(
195
+ db: Database,
196
+ profileIds: string[],
197
+ ) {
198
+ const uniqueProfileIds = Array.from(new Set(profileIds)).filter(Boolean);
199
+ if (uniqueProfileIds.length === 0) {
200
+ return { profiles: 0, entries: 0 };
201
+ }
202
+
203
+ const placeholders = uniqueProfileIds.map(() => "?").join(",");
204
+ const rows = db
205
+ .prepare(
206
+ `
207
+ select id, handle, display_name, bio, followers_count, following_count,
208
+ avatar_hue, avatar_url, location, url, verified_type, entities_json, created_at
209
+ from profiles
210
+ where id in (${placeholders})
211
+ `,
212
+ )
213
+ .all(...uniqueProfileIds) as Array<Record<string, unknown>>;
214
+ const profiles = rows.map(profileFromRow);
215
+ const affiliationsByProfile = fetchProfileAffiliations(db, uniqueProfileIds);
216
+ const bioEntitiesByProfile = fetchProfileBioEntities(db, uniqueProfileIds);
217
+ const snapshotsByProfile = fetchProfileSnapshots(db, uniqueProfileIds);
218
+ const entries = new Map<string, IdentityIndexEntry>();
219
+
220
+ for (const profile of profiles) {
221
+ for (const [key, entry] of collectProfileEntries(profile)) {
222
+ entries.set(key, entry);
223
+ }
224
+ for (const affiliation of affiliationsByProfile.get(profile.id) ?? []) {
225
+ addAffiliationEntries(entries, profile.id, [
226
+ affiliation.organizationName,
227
+ affiliation.organizationHandle,
228
+ affiliation.label,
229
+ affiliation.url,
230
+ affiliation.organizationProfileId,
231
+ ]);
232
+ }
233
+ for (const entity of bioEntitiesByProfile.get(profile.id) ?? []) {
234
+ addEntry(entries, {
235
+ profileId: profile.id,
236
+ kind:
237
+ entity.kind === "handle"
238
+ ? "bio_handle"
239
+ : entity.kind === "domain"
240
+ ? "bio_domain"
241
+ : "bio_company",
242
+ value: entity.value,
243
+ source: "bio_entity",
244
+ });
245
+ }
246
+ for (const snapshot of snapshotsByProfile.get(profile.id) ?? []) {
247
+ addEntry(entries, {
248
+ profileId: profile.id,
249
+ kind: "profile_history",
250
+ value: snapshot.handle,
251
+ source: "history",
252
+ });
253
+ addEntry(entries, {
254
+ profileId: profile.id,
255
+ kind: "profile_history",
256
+ value: snapshot.displayName,
257
+ source: "history",
258
+ });
259
+ addEntry(entries, {
260
+ profileId: profile.id,
261
+ kind: "profile_history",
262
+ value: snapshot.bio,
263
+ source: "history",
264
+ });
265
+ if (snapshot.location) {
266
+ addEntry(entries, {
267
+ profileId: profile.id,
268
+ kind: "profile_history",
269
+ value: snapshot.location,
270
+ source: "history",
271
+ });
272
+ }
273
+ if (snapshot.url) {
274
+ addEntry(entries, {
275
+ profileId: profile.id,
276
+ kind: "profile_history",
277
+ value: snapshot.url,
278
+ source: "history",
279
+ });
280
+ }
281
+ for (const affiliation of snapshot.affiliations) {
282
+ if (!affiliation || typeof affiliation !== "object") {
283
+ continue;
284
+ }
285
+ const record = affiliation as Record<string, unknown>;
286
+ addAffiliationEntries(
287
+ entries,
288
+ profile.id,
289
+ [
290
+ record.organizationName,
291
+ record.organizationHandle,
292
+ record.label,
293
+ record.url,
294
+ ].filter((value): value is string => typeof value === "string"),
295
+ "history",
296
+ );
297
+ }
298
+ }
299
+ }
300
+
301
+ const now = new Date().toISOString();
302
+ const deleteStatement = db.prepare(
303
+ `delete from identity_search_index where profile_id = ?`,
304
+ );
305
+ const insertStatement = db.prepare(
306
+ `
307
+ insert into identity_search_index (
308
+ profile_id, kind, value, normalized_value, source, weight, updated_at
309
+ ) values (?, ?, ?, ?, ?, ?, ?)
310
+ on conflict(profile_id, kind, value, source) do update set
311
+ normalized_value = excluded.normalized_value,
312
+ weight = excluded.weight,
313
+ updated_at = excluded.updated_at
314
+ `,
315
+ );
316
+ db.transaction(() => {
317
+ for (const profileId of uniqueProfileIds) {
318
+ deleteStatement.run(profileId);
319
+ }
320
+ for (const entry of entries.values()) {
321
+ insertStatement.run(
322
+ entry.profileId,
323
+ entry.kind,
324
+ entry.value,
325
+ normalizeIndexValue(entry.value),
326
+ entry.source,
327
+ entry.weight,
328
+ now,
329
+ );
330
+ }
331
+ })();
332
+
333
+ return { profiles: profiles.length, entries: entries.size };
334
+ }
335
+
336
+ export function ensureIdentitySearchIndexForDmProfiles(
337
+ db: Database,
338
+ account?: string,
339
+ ) {
340
+ const params: string[] = [];
341
+ let accountClause = "";
342
+ if (account && account !== "all") {
343
+ accountClause = "where c.account_id = ?";
344
+ params.push(account);
345
+ }
346
+ const rows = db
347
+ .prepare(
348
+ `
349
+ select distinct c.participant_profile_id as profile_id
350
+ from dm_conversations c
351
+ left join identity_search_index isi
352
+ on isi.profile_id = c.participant_profile_id
353
+ ${accountClause}
354
+ group by c.participant_profile_id
355
+ having count(isi.profile_id) = 0
356
+ `,
357
+ )
358
+ .all(...params) as Array<{ profile_id: string }>;
359
+
360
+ return syncIdentitySearchIndexForProfileIds(
361
+ db,
362
+ rows.map((row) => row.profile_id),
363
+ );
364
+ }
365
+
366
+ export const __test__ = {
367
+ normalizeIndexValue,
368
+ getProfileBioUrls,
369
+ };
@@ -0,0 +1,267 @@
1
+ import type { Database } from "./sqlite";
2
+ import { listThreadViaBird } from "./bird";
3
+ import { getNativeDb } from "./db";
4
+ import type { XurlMentionData, XurlMentionsResponse } from "./types";
5
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
6
+
7
+ const DEFAULT_LIMIT = 30;
8
+ const DEFAULT_DELAY_MS = 1500;
9
+ const DEFAULT_TIMEOUT_MS = 15_000;
10
+
11
+ function assertPositiveInteger(value: number, name: string) {
12
+ if (!Number.isFinite(value) || value < 1) {
13
+ throw new Error(`${name} must be at least 1`);
14
+ }
15
+ return Math.floor(value);
16
+ }
17
+
18
+ function parseNonNegativeInteger(value: number | undefined, name: string) {
19
+ if (value === undefined) {
20
+ return undefined;
21
+ }
22
+ if (!Number.isFinite(value) || value < 0) {
23
+ throw new Error(`${name} must be non-negative`);
24
+ }
25
+ return Math.floor(value);
26
+ }
27
+
28
+ function sleep(ms: number) {
29
+ return new Promise((resolve) => setTimeout(resolve, ms));
30
+ }
31
+
32
+ function getMediaCount(tweet: XurlMentionData) {
33
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
34
+ return urls.filter(
35
+ (url) =>
36
+ url &&
37
+ typeof url === "object" &&
38
+ typeof (url as Record<string, unknown>).media_key === "string",
39
+ ).length;
40
+ }
41
+
42
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
43
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
44
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
45
+ tweetId,
46
+ text,
47
+ );
48
+ }
49
+
50
+ function resolveAccount(db: Database, accountId?: string) {
51
+ const row = accountId
52
+ ? (db
53
+ .prepare("select id, handle from accounts where id = ?")
54
+ .get(accountId) as { id: string; handle: string } | undefined)
55
+ : (db
56
+ .prepare(
57
+ `
58
+ select id, handle
59
+ from accounts
60
+ order by is_default desc, created_at asc
61
+ limit 1
62
+ `,
63
+ )
64
+ .get() as { id: string; handle: string } | undefined);
65
+
66
+ if (!row) {
67
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
68
+ }
69
+
70
+ return {
71
+ accountId: row.id,
72
+ handle: row.handle.replace(/^@/, "").toLowerCase(),
73
+ };
74
+ }
75
+
76
+ function listRecentMentionIds(db: Database, accountId: string, limit: number) {
77
+ return (
78
+ db
79
+ .prepare(
80
+ `
81
+ select id
82
+ from tweets
83
+ where kind = 'mention' and account_id = ?
84
+ order by created_at desc
85
+ limit ?
86
+ `,
87
+ )
88
+ .all(accountId, limit) as Array<{ id: string }>
89
+ ).map((row) => row.id);
90
+ }
91
+
92
+ function getReplyToId(tweet: XurlMentionData) {
93
+ return tweet.referenced_tweets?.find((entry) => entry.type === "replied_to")
94
+ ?.id;
95
+ }
96
+
97
+ function mergeMentionThreadIntoLocalStore({
98
+ db,
99
+ accountId,
100
+ accountHandle,
101
+ mentionIds,
102
+ payload,
103
+ }: {
104
+ db: Database;
105
+ accountId: string;
106
+ accountHandle: string;
107
+ mentionIds: Set<string>;
108
+ payload: XurlMentionsResponse;
109
+ }) {
110
+ const usersById = new Map(
111
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
112
+ );
113
+ const upsertTweet = db.prepare(
114
+ `
115
+ insert into tweets (
116
+ id, account_id, author_profile_id, kind, text, created_at,
117
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
118
+ entities_json, media_json, quoted_tweet_id
119
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, '[]', null)
120
+ on conflict(id) do update set
121
+ account_id = excluded.account_id,
122
+ author_profile_id = excluded.author_profile_id,
123
+ kind = case
124
+ when tweets.kind in ('home', 'mention') then tweets.kind
125
+ when excluded.kind in ('home', 'mention') then excluded.kind
126
+ else coalesce(nullif(tweets.kind, ''), excluded.kind)
127
+ end,
128
+ text = excluded.text,
129
+ created_at = excluded.created_at,
130
+ is_replied = max(tweets.is_replied, excluded.is_replied),
131
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
132
+ like_count = excluded.like_count,
133
+ media_count = excluded.media_count,
134
+ entities_json = excluded.entities_json,
135
+ media_json = excluded.media_json,
136
+ bookmarked = tweets.bookmarked,
137
+ liked = tweets.liked
138
+ `,
139
+ );
140
+
141
+ db.transaction(() => {
142
+ for (const tweet of payload.data) {
143
+ const author =
144
+ usersById.get(tweet.author_id) ??
145
+ ({
146
+ id: tweet.author_id,
147
+ username: `user_${tweet.author_id}`,
148
+ name: `user_${tweet.author_id}`,
149
+ } as const);
150
+ const profile = usersById.has(tweet.author_id)
151
+ ? upsertProfileFromXUser(db, author)
152
+ : ensureStubProfileForXUser(db, tweet.author_id);
153
+ const handle = author.username.toLowerCase();
154
+ const kind = mentionIds.has(tweet.id)
155
+ ? "mention"
156
+ : handle === accountHandle
157
+ ? "home"
158
+ : "thread";
159
+ const replyToId = getReplyToId(tweet);
160
+ upsertTweet.run(
161
+ tweet.id,
162
+ accountId,
163
+ profile.profile.id,
164
+ kind,
165
+ tweet.text,
166
+ tweet.created_at,
167
+ replyToId ? 1 : 0,
168
+ replyToId ?? null,
169
+ Number(tweet.public_metrics?.like_count ?? 0),
170
+ getMediaCount(tweet),
171
+ JSON.stringify(tweet.entities ?? {}),
172
+ );
173
+ replaceTweetFts(db, tweet.id, tweet.text);
174
+ }
175
+ })();
176
+ }
177
+
178
+ export async function syncMentionThreads({
179
+ account,
180
+ limit = DEFAULT_LIMIT,
181
+ delayMs = DEFAULT_DELAY_MS,
182
+ timeoutMs = DEFAULT_TIMEOUT_MS,
183
+ all = false,
184
+ maxPages,
185
+ }: {
186
+ account?: string;
187
+ limit?: number;
188
+ delayMs?: number;
189
+ timeoutMs?: number;
190
+ all?: boolean;
191
+ maxPages?: number;
192
+ }) {
193
+ const parsedLimit = assertPositiveInteger(limit, "--limit");
194
+ const parsedDelayMs = parseNonNegativeInteger(delayMs, "--delay-ms") ?? 0;
195
+ const parsedTimeoutMs = assertPositiveInteger(timeoutMs, "--timeout-ms");
196
+ const parsedMaxPages = parseNonNegativeInteger(maxPages, "--max-pages");
197
+ const db = getNativeDb();
198
+ const resolvedAccount = resolveAccount(db, account);
199
+ const mentionIds = listRecentMentionIds(
200
+ db,
201
+ resolvedAccount.accountId,
202
+ parsedLimit,
203
+ );
204
+ const mentionIdSet = new Set(mentionIds);
205
+ const results: Array<{
206
+ tweetId: string;
207
+ ok: boolean;
208
+ count: number;
209
+ error?: string;
210
+ }> = [];
211
+ let mergedTweets = 0;
212
+ const uniqueTweetIds = new Set<string>();
213
+
214
+ for (const [index, tweetId] of mentionIds.entries()) {
215
+ if (index > 0 && parsedDelayMs > 0) {
216
+ await sleep(parsedDelayMs);
217
+ }
218
+ try {
219
+ const payload = await listThreadViaBird({
220
+ tweetId,
221
+ all,
222
+ maxPages: parsedMaxPages,
223
+ timeoutMs: parsedTimeoutMs,
224
+ });
225
+ mergeMentionThreadIntoLocalStore({
226
+ db,
227
+ accountId: resolvedAccount.accountId,
228
+ accountHandle: resolvedAccount.handle,
229
+ mentionIds: mentionIdSet,
230
+ payload,
231
+ });
232
+ for (const tweet of payload.data) {
233
+ uniqueTweetIds.add(tweet.id);
234
+ }
235
+ mergedTweets += payload.data.length;
236
+ results.push({ tweetId, ok: true, count: payload.data.length });
237
+ } catch (error) {
238
+ results.push({
239
+ tweetId,
240
+ ok: false,
241
+ count: 0,
242
+ error: error instanceof Error ? error.message : String(error),
243
+ });
244
+ }
245
+ }
246
+
247
+ const failures = results.filter((item) => !item.ok);
248
+ return {
249
+ ok: true,
250
+ accountId: resolvedAccount.accountId,
251
+ mentions: mentionIds.length,
252
+ threads: results.length,
253
+ succeeded: results.length - failures.length,
254
+ failed: failures.length,
255
+ mergedTweets,
256
+ uniqueTweets: uniqueTweetIds.size,
257
+ options: {
258
+ limit: parsedLimit,
259
+ delayMs: parsedDelayMs,
260
+ timeoutMs: parsedTimeoutMs,
261
+ all,
262
+ maxPages: parsedMaxPages ?? null,
263
+ },
264
+ results,
265
+ failures,
266
+ };
267
+ }
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
  import { listMentionsViaBird } from "./bird";
3
3
  import type { MentionsDataSource } from "./config";
4
4
  import { getNativeDb } from "./db";
@@ -12,6 +12,7 @@ import type {
12
12
  XurlMentionsResponse,
13
13
  XurlMentionUser,
14
14
  } from "./types";
15
+ import { upsertTweetAccountEdge } from "./tweet-account-edges";
15
16
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
16
17
  import { listMentionsViaXurl, lookupUsersByHandles } from "./xurl";
17
18
 
@@ -64,7 +65,7 @@ function parseMaxPages(value?: number) {
64
65
  return Math.floor(value);
65
66
  }
66
67
 
67
- function resolveAccount(db: Database.Database, accountId?: string) {
68
+ function resolveAccount(db: Database, accountId?: string) {
68
69
  const row = accountId
69
70
  ? (db
70
71
  .prepare("select id, handle from accounts where id = ?")
@@ -149,7 +150,7 @@ function getMediaCount(tweet: XurlMentionData) {
149
150
  ).length;
150
151
  }
151
152
 
152
- function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
153
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
153
154
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
154
155
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
155
156
  tweetId,
@@ -158,9 +159,10 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
158
159
  }
159
160
 
160
161
  function mergeMentionsIntoLocalStore(
161
- db: Database.Database,
162
+ db: Database,
162
163
  accountId: string,
163
164
  payload: XurlMentionsResponse,
165
+ source: MentionsDataSource,
164
166
  ) {
165
167
  const usersById = new Map(
166
168
  (payload.includes?.users ?? []).map((user) => [user.id, user]),
@@ -173,9 +175,9 @@ function mergeMentionsIntoLocalStore(
173
175
  entities_json, media_json, quoted_tweet_id
174
176
  ) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
175
177
  on conflict(id) do update set
176
- account_id = excluded.account_id,
178
+ account_id = tweets.account_id,
177
179
  author_profile_id = excluded.author_profile_id,
178
- kind = excluded.kind,
180
+ kind = case when tweets.kind = 'home' then tweets.kind else excluded.kind end,
179
181
  text = excluded.text,
180
182
  created_at = excluded.created_at,
181
183
  like_count = excluded.like_count,
@@ -189,6 +191,7 @@ function mergeMentionsIntoLocalStore(
189
191
  );
190
192
 
191
193
  const writePayload = db.transaction(() => {
194
+ const seenAt = new Date().toISOString();
192
195
  for (const tweet of payload.data) {
193
196
  const author =
194
197
  usersById.get(tweet.author_id) ??
@@ -210,6 +213,14 @@ function mergeMentionsIntoLocalStore(
210
213
  getMediaCount(tweet),
211
214
  JSON.stringify(toLocalEntities(tweet)),
212
215
  );
216
+ upsertTweetAccountEdge(db, {
217
+ accountId,
218
+ tweetId: tweet.id,
219
+ kind: "mention",
220
+ source,
221
+ seenAt,
222
+ rawJson: JSON.stringify(tweet),
223
+ });
213
224
  replaceTweetFts(db, tweet.id, tweet.text);
214
225
  }
215
226
  });
@@ -408,7 +419,7 @@ async function exportMentionsViaCachedLiveSource({
408
419
  all: fetchAll,
409
420
  parsedMaxPages,
410
421
  });
411
- mergeMentionsIntoLocalStore(db, resolvedAccount.accountId, payload);
422
+ mergeMentionsIntoLocalStore(db, resolvedAccount.accountId, payload, mode);
412
423
  writeSyncCache(cacheKey, payload, db);
413
424
 
414
425
  if (