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.
- package/CHANGELOG.md +44 -1
- package/README.md +54 -15
- package/package.json +14 -15
- package/playwright.config.ts +5 -2
- package/src/cli.ts +289 -20
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +34 -2
- package/src/lib/backup.ts +271 -33
- package/src/lib/bird-actions.ts +21 -0
- package/src/lib/bird.ts +332 -17
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +321 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/mention-threads-live.ts +267 -0
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +7 -5
- package/src/lib/moderation-write.ts +3 -3
- package/src/lib/profile-affiliation-hydration.ts +189 -0
- package/src/lib/profile-affiliations.ts +262 -0
- package/src/lib/profile-bio-entities.ts +318 -0
- package/src/lib/profile-history.ts +164 -0
- package/src/lib/profile-hydration.ts +8 -24
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +522 -68
- package/src/lib/research.ts +607 -0
- package/src/lib/seed.ts +10 -4
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +22 -8
- package/src/lib/timeline-live.ts +182 -0
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +103 -1
- package/src/lib/url-expansion.ts +147 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +174 -17
- 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
|
+
}
|
package/src/lib/mentions-live.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type Database from "
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|