birdclaw 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +108 -7
- package/package.json +24 -23
- package/playwright.config.ts +1 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +2 -2
- package/src/cli.ts +563 -18
- package/src/components/AppNav.tsx +66 -29
- package/src/components/AvatarChip.tsx +10 -5
- package/src/components/ConversationThread.tsx +125 -0
- package/src/components/DmWorkspace.tsx +96 -98
- package/src/components/EmbeddedTweetCard.tsx +20 -14
- package/src/components/InboxCard.tsx +92 -90
- package/src/components/LinkPreviewCard.tsx +270 -0
- package/src/components/ProfilePreview.tsx +8 -3
- package/src/components/SavedTimelineView.tsx +48 -38
- package/src/components/TimelineCard.tsx +228 -84
- package/src/components/TweetRichText.tsx +6 -2
- package/src/lib/archive-import.ts +1567 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +318 -14
- package/src/lib/bird-actions.ts +1 -10
- package/src/lib/bird-command.ts +57 -0
- package/src/lib/bird.ts +89 -5
- package/src/lib/config.ts +1 -1
- package/src/lib/db.ts +282 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +629 -0
- package/src/lib/link-insights.ts +834 -0
- package/src/lib/link-preview-metadata.ts +334 -0
- package/src/lib/media-fetch.ts +787 -0
- package/src/lib/media-includes.ts +165 -0
- package/src/lib/mention-threads-live.ts +535 -43
- package/src/lib/mentions-live.ts +623 -19
- package/src/lib/moderation-target.ts +6 -0
- package/src/lib/profile-hydration.ts +1 -1
- package/src/lib/profile-resolver.ts +115 -1
- package/src/lib/queries.ts +233 -7
- package/src/lib/seed.ts +127 -8
- package/src/lib/timeline-collections-live.ts +145 -22
- package/src/lib/timeline-live.ts +10 -15
- package/src/lib/tweet-account-edges.ts +9 -2
- package/src/lib/tweet-render.ts +97 -0
- package/src/lib/types.ts +219 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +33 -8
- package/src/lib/x-profile.ts +7 -2
- package/src/lib/xurl.ts +296 -12
- package/src/routeTree.gen.ts +105 -0
- package/src/routes/__root.tsx +7 -3
- package/src/routes/api/conversation.tsx +34 -0
- package/src/routes/api/link-insights.tsx +79 -0
- package/src/routes/api/link-preview.tsx +43 -0
- package/src/routes/api/profile-hydrate.tsx +51 -0
- package/src/routes/blocks.tsx +111 -86
- package/src/routes/dms.tsx +90 -78
- package/src/routes/inbox.tsx +98 -86
- package/src/routes/index.tsx +63 -50
- package/src/routes/links.tsx +883 -0
- package/src/routes/mentions.tsx +63 -50
- package/src/styles.css +106 -43
package/src/lib/mentions-live.ts
CHANGED
|
@@ -2,14 +2,16 @@ import type { Database } from "./sqlite";
|
|
|
2
2
|
import { listMentionsViaBird } from "./bird";
|
|
3
3
|
import type { MentionsDataSource } from "./config";
|
|
4
4
|
import { getNativeDb } from "./db";
|
|
5
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
5
6
|
import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
|
|
6
7
|
import { listTimelineItems } from "./queries";
|
|
7
|
-
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
|
+
import { deleteSyncCache, readSyncCache, writeSyncCache } from "./sync-cache";
|
|
8
9
|
import type {
|
|
9
10
|
ReplyFilter,
|
|
10
11
|
TweetEntities,
|
|
11
12
|
XurlMentionData,
|
|
12
13
|
XurlMentionsResponse,
|
|
14
|
+
XurlMediaItem,
|
|
13
15
|
XurlMentionUser,
|
|
14
16
|
} from "./types";
|
|
15
17
|
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
@@ -19,21 +21,187 @@ import { listMentionsViaXurl, lookupUsersByHandles } from "./xurl";
|
|
|
19
21
|
export const DEFAULT_MENTIONS_CACHE_TTL_MS = 2 * 60_000;
|
|
20
22
|
const MIN_XURL_MENTIONS_LIMIT = 5;
|
|
21
23
|
const MAX_XURL_MENTIONS_LIMIT = 100;
|
|
24
|
+
type MentionSyncMode = Exclude<MentionsDataSource, "birdclaw">;
|
|
25
|
+
type MentionScanBoundary =
|
|
26
|
+
| { kind: "auto" }
|
|
27
|
+
| { kind: "since"; sinceId: string }
|
|
28
|
+
| { kind: "start"; startTime: string }
|
|
29
|
+
| { kind: "unbounded" };
|
|
30
|
+
interface MentionScanShape {
|
|
31
|
+
endpoint: "mentions";
|
|
32
|
+
mode: MentionSyncMode;
|
|
33
|
+
accountId: string;
|
|
34
|
+
pageSize: number;
|
|
35
|
+
boundary: MentionScanBoundary;
|
|
36
|
+
}
|
|
37
|
+
interface MentionCursorValue extends XurlMentionsResponse {
|
|
38
|
+
birdclaw?: {
|
|
39
|
+
boundary?: MentionScanBoundary;
|
|
40
|
+
pendingNewestId?: string | null;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
interface MentionHighWaterValue {
|
|
44
|
+
sinceId: string;
|
|
45
|
+
}
|
|
22
46
|
|
|
23
47
|
function getMentionsFetchModeKey({
|
|
48
|
+
scope,
|
|
49
|
+
mode,
|
|
50
|
+
accountId,
|
|
51
|
+
pageSize,
|
|
52
|
+
all,
|
|
53
|
+
maxPages,
|
|
54
|
+
sinceId,
|
|
55
|
+
startTime,
|
|
56
|
+
}: {
|
|
57
|
+
scope: "sync" | "export";
|
|
58
|
+
mode: MentionsDataSource;
|
|
59
|
+
accountId: string;
|
|
60
|
+
pageSize: number;
|
|
61
|
+
all: boolean;
|
|
62
|
+
maxPages: number | null;
|
|
63
|
+
sinceId: string | null;
|
|
64
|
+
startTime: string | null;
|
|
65
|
+
}) {
|
|
66
|
+
return `mentions:${scope}:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}:${sinceId ?? "no-since"}:${startTime ?? "no-start"}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getLegacyMentionsFetchModeKeyWithoutStart({
|
|
70
|
+
scope,
|
|
24
71
|
mode,
|
|
25
72
|
accountId,
|
|
26
73
|
pageSize,
|
|
27
74
|
all,
|
|
28
75
|
maxPages,
|
|
76
|
+
sinceId,
|
|
29
77
|
}: {
|
|
78
|
+
scope: "sync" | "export";
|
|
30
79
|
mode: MentionsDataSource;
|
|
31
80
|
accountId: string;
|
|
32
81
|
pageSize: number;
|
|
33
82
|
all: boolean;
|
|
34
83
|
maxPages: number | null;
|
|
84
|
+
sinceId: string | null;
|
|
85
|
+
}) {
|
|
86
|
+
return `mentions:${scope}:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}:${sinceId ?? "no-since"}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function encodeCacheKeyPart(value: string) {
|
|
90
|
+
return encodeURIComponent(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getMentionScanBoundaryKey(boundary: MentionScanBoundary) {
|
|
94
|
+
switch (boundary.kind) {
|
|
95
|
+
case "auto":
|
|
96
|
+
return "auto";
|
|
97
|
+
case "since":
|
|
98
|
+
return `since=${encodeCacheKeyPart(boundary.sinceId)}`;
|
|
99
|
+
case "start":
|
|
100
|
+
return `start=${encodeCacheKeyPart(boundary.startTime)}`;
|
|
101
|
+
case "unbounded":
|
|
102
|
+
return "unbounded";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getMentionScanShapeKey(shape: MentionScanShape) {
|
|
107
|
+
return [
|
|
108
|
+
`endpoint=${shape.endpoint}`,
|
|
109
|
+
`mode=${shape.mode}`,
|
|
110
|
+
`account=${encodeCacheKeyPart(shape.accountId)}`,
|
|
111
|
+
`page=${String(shape.pageSize)}`,
|
|
112
|
+
`boundary=${getMentionScanBoundaryKey(shape.boundary)}`,
|
|
113
|
+
].join(":");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getMentionCursorKey(shape: MentionScanShape) {
|
|
117
|
+
return `mentions:sync:cursor:v2:${getMentionScanShapeKey(shape)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getMentionResultCacheKey({
|
|
121
|
+
shape,
|
|
122
|
+
all,
|
|
123
|
+
maxPages,
|
|
124
|
+
}: {
|
|
125
|
+
shape: MentionScanShape;
|
|
126
|
+
all: boolean;
|
|
127
|
+
maxPages: number | null;
|
|
128
|
+
}) {
|
|
129
|
+
return `mentions:sync:result:v2:${getMentionScanShapeKey(shape)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getMentionHighWaterKey({
|
|
133
|
+
mode,
|
|
134
|
+
accountId,
|
|
135
|
+
}: {
|
|
136
|
+
mode: MentionSyncMode;
|
|
137
|
+
accountId: string;
|
|
35
138
|
}) {
|
|
36
|
-
return `mentions
|
|
139
|
+
return `mentions:sync:high-water:v1:mode=${mode}:account=${encodeCacheKeyPart(accountId)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getMentionCursorBoundary({
|
|
143
|
+
explicitSinceId,
|
|
144
|
+
explicitStartTime,
|
|
145
|
+
}: {
|
|
146
|
+
explicitSinceId?: string;
|
|
147
|
+
explicitStartTime?: string;
|
|
148
|
+
}): MentionScanBoundary {
|
|
149
|
+
if (explicitSinceId) {
|
|
150
|
+
return { kind: "since", sinceId: explicitSinceId };
|
|
151
|
+
}
|
|
152
|
+
if (explicitStartTime) {
|
|
153
|
+
return { kind: "start", startTime: explicitStartTime };
|
|
154
|
+
}
|
|
155
|
+
return { kind: "auto" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getMentionRequestBoundary({
|
|
159
|
+
sinceId,
|
|
160
|
+
startTime,
|
|
161
|
+
}: {
|
|
162
|
+
sinceId?: string;
|
|
163
|
+
startTime?: string;
|
|
164
|
+
}): MentionScanBoundary {
|
|
165
|
+
if (sinceId) {
|
|
166
|
+
return { kind: "since", sinceId };
|
|
167
|
+
}
|
|
168
|
+
if (startTime) {
|
|
169
|
+
return { kind: "start", startTime };
|
|
170
|
+
}
|
|
171
|
+
return { kind: "unbounded" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getLegacyMentionCursorKeys(shape: MentionScanShape) {
|
|
175
|
+
const sinceId =
|
|
176
|
+
shape.boundary.kind === "since" ? shape.boundary.sinceId : null;
|
|
177
|
+
const startTime =
|
|
178
|
+
shape.boundary.kind === "start" ? shape.boundary.startTime : null;
|
|
179
|
+
const keys = [
|
|
180
|
+
getMentionsFetchModeKey({
|
|
181
|
+
scope: "sync",
|
|
182
|
+
mode: shape.mode,
|
|
183
|
+
accountId: shape.accountId,
|
|
184
|
+
pageSize: shape.pageSize,
|
|
185
|
+
all: false,
|
|
186
|
+
maxPages: null,
|
|
187
|
+
sinceId,
|
|
188
|
+
startTime,
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
if (!startTime) {
|
|
192
|
+
keys.push(
|
|
193
|
+
getLegacyMentionsFetchModeKeyWithoutStart({
|
|
194
|
+
scope: "sync",
|
|
195
|
+
mode: shape.mode,
|
|
196
|
+
accountId: shape.accountId,
|
|
197
|
+
pageSize: shape.pageSize,
|
|
198
|
+
all: false,
|
|
199
|
+
maxPages: null,
|
|
200
|
+
sinceId,
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return [...new Set(keys)];
|
|
37
205
|
}
|
|
38
206
|
|
|
39
207
|
function parseCacheTtlMs(value?: number) {
|
|
@@ -65,6 +233,155 @@ function parseMaxPages(value?: number) {
|
|
|
65
233
|
return Math.floor(value);
|
|
66
234
|
}
|
|
67
235
|
|
|
236
|
+
function parseSyncMode(value?: string): MentionSyncMode {
|
|
237
|
+
const mode = value ?? "xurl";
|
|
238
|
+
if (mode !== "bird" && mode !== "xurl") {
|
|
239
|
+
throw new Error("--mode must be bird or xurl");
|
|
240
|
+
}
|
|
241
|
+
return mode;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getCachedPaginationToken(
|
|
245
|
+
cached?: { value: XurlMentionsResponse } | null,
|
|
246
|
+
) {
|
|
247
|
+
return typeof cached?.value.meta?.next_token === "string" &&
|
|
248
|
+
cached.value.meta.next_token.length > 0
|
|
249
|
+
? cached.value.meta.next_token
|
|
250
|
+
: undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseCachedMentionBoundary(
|
|
254
|
+
value: MentionCursorValue | XurlMentionsResponse,
|
|
255
|
+
fallbackBoundary?: MentionScanBoundary,
|
|
256
|
+
) {
|
|
257
|
+
const boundary = (value as MentionCursorValue).birdclaw?.boundary;
|
|
258
|
+
if (!boundary || typeof boundary !== "object") {
|
|
259
|
+
return fallbackBoundary;
|
|
260
|
+
}
|
|
261
|
+
if (boundary.kind === "unbounded" || boundary.kind === "auto") {
|
|
262
|
+
return boundary;
|
|
263
|
+
}
|
|
264
|
+
if (boundary.kind === "since" && typeof boundary.sinceId === "string") {
|
|
265
|
+
return boundary;
|
|
266
|
+
}
|
|
267
|
+
if (boundary.kind === "start" && typeof boundary.startTime === "string") {
|
|
268
|
+
return boundary;
|
|
269
|
+
}
|
|
270
|
+
return fallbackBoundary;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getCachedMentionPendingNewestId(
|
|
274
|
+
value: MentionCursorValue | XurlMentionsResponse | undefined,
|
|
275
|
+
) {
|
|
276
|
+
const pendingNewestId = (value as MentionCursorValue | undefined)?.birdclaw
|
|
277
|
+
?.pendingNewestId;
|
|
278
|
+
return isNumericTweetId(pendingNewestId) ? pendingNewestId : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function addMentionCursorState(
|
|
282
|
+
payload: XurlMentionsResponse,
|
|
283
|
+
boundary: MentionScanBoundary,
|
|
284
|
+
pendingNewestId: string | undefined,
|
|
285
|
+
): MentionCursorValue {
|
|
286
|
+
return {
|
|
287
|
+
...payload,
|
|
288
|
+
birdclaw: { boundary, pendingNewestId: pendingNewestId ?? null },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function readMentionCursor({
|
|
293
|
+
db,
|
|
294
|
+
shape,
|
|
295
|
+
cursorKey,
|
|
296
|
+
legacyCursorKeys,
|
|
297
|
+
}: {
|
|
298
|
+
db: Database;
|
|
299
|
+
shape: MentionScanShape;
|
|
300
|
+
cursorKey: string;
|
|
301
|
+
legacyCursorKeys: string[];
|
|
302
|
+
}) {
|
|
303
|
+
const fallbackBoundary =
|
|
304
|
+
shape.boundary.kind === "auto" ? undefined : shape.boundary;
|
|
305
|
+
const current = readSyncCache<MentionCursorValue>(cursorKey, db);
|
|
306
|
+
const currentToken = getCachedPaginationToken(current);
|
|
307
|
+
if (current && currentToken) {
|
|
308
|
+
return {
|
|
309
|
+
key: cursorKey,
|
|
310
|
+
token: currentToken,
|
|
311
|
+
boundary: parseCachedMentionBoundary(current.value, fallbackBoundary),
|
|
312
|
+
pendingNewestId: getCachedMentionPendingNewestId(current.value),
|
|
313
|
+
legacyKeys: [] as string[],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const legacyKey of legacyCursorKeys) {
|
|
318
|
+
const legacy = readSyncCache<MentionCursorValue>(legacyKey, db);
|
|
319
|
+
const legacyToken = getCachedPaginationToken(legacy);
|
|
320
|
+
if (legacy && legacyToken) {
|
|
321
|
+
return {
|
|
322
|
+
key: legacyKey,
|
|
323
|
+
token: legacyToken,
|
|
324
|
+
boundary: parseCachedMentionBoundary(legacy.value, fallbackBoundary),
|
|
325
|
+
pendingNewestId: getCachedMentionPendingNewestId(legacy.value),
|
|
326
|
+
legacyKeys: [legacyKey],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function isNumericTweetId(value: string | undefined | null): value is string {
|
|
335
|
+
return typeof value === "string" && /^[0-9]+$/.test(value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function maxNumericTweetId(...ids: Array<string | undefined | null>) {
|
|
339
|
+
return ids.filter(isNumericTweetId).reduce<string | undefined>((max, id) => {
|
|
340
|
+
if (!max) {
|
|
341
|
+
return id;
|
|
342
|
+
}
|
|
343
|
+
if (id.length !== max.length) {
|
|
344
|
+
return id.length > max.length ? id : max;
|
|
345
|
+
}
|
|
346
|
+
return id > max ? id : max;
|
|
347
|
+
}, undefined);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getNewestMentionId(payload: XurlMentionsResponse) {
|
|
351
|
+
return maxNumericTweetId(
|
|
352
|
+
typeof payload.meta?.newest_id === "string"
|
|
353
|
+
? payload.meta.newest_id
|
|
354
|
+
: undefined,
|
|
355
|
+
...payload.data.map((tweet) => tweet.id),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readMentionHighWaterId(
|
|
360
|
+
db: Database,
|
|
361
|
+
mode: MentionSyncMode,
|
|
362
|
+
accountId: string,
|
|
363
|
+
) {
|
|
364
|
+
const cached = readSyncCache<MentionHighWaterValue>(
|
|
365
|
+
getMentionHighWaterKey({ mode, accountId }),
|
|
366
|
+
db,
|
|
367
|
+
);
|
|
368
|
+
return isNumericTweetId(cached?.value.sinceId)
|
|
369
|
+
? cached.value.sinceId
|
|
370
|
+
: undefined;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function writeMentionHighWaterId(
|
|
374
|
+
db: Database,
|
|
375
|
+
mode: MentionSyncMode,
|
|
376
|
+
accountId: string,
|
|
377
|
+
sinceId: string | undefined,
|
|
378
|
+
) {
|
|
379
|
+
if (!isNumericTweetId(sinceId)) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
writeSyncCache(getMentionHighWaterKey({ mode, accountId }), { sinceId }, db);
|
|
383
|
+
}
|
|
384
|
+
|
|
68
385
|
function resolveAccount(db: Database, accountId?: string) {
|
|
69
386
|
const row = accountId
|
|
70
387
|
? (db
|
|
@@ -91,6 +408,28 @@ function resolveAccount(db: Database, accountId?: string) {
|
|
|
91
408
|
};
|
|
92
409
|
}
|
|
93
410
|
|
|
411
|
+
function findNewestArchiveMentionId(db: Database, accountId: string) {
|
|
412
|
+
const row = db
|
|
413
|
+
.prepare(
|
|
414
|
+
`
|
|
415
|
+
select t.id
|
|
416
|
+
from tweets t
|
|
417
|
+
join tweet_account_edges e
|
|
418
|
+
on e.tweet_id = t.id
|
|
419
|
+
where e.account_id = ?
|
|
420
|
+
and e.kind = 'mention'
|
|
421
|
+
and e.source in ('archive', 'legacy')
|
|
422
|
+
and length(t.id) > 0
|
|
423
|
+
and t.id glob '[0-9]*'
|
|
424
|
+
and t.id not glob '*[^0-9]*'
|
|
425
|
+
order by length(t.id) desc, t.id desc
|
|
426
|
+
limit 1
|
|
427
|
+
`,
|
|
428
|
+
)
|
|
429
|
+
.get(accountId) as { id: string } | undefined;
|
|
430
|
+
return row?.id;
|
|
431
|
+
}
|
|
432
|
+
|
|
94
433
|
function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
95
434
|
const raw = tweet.entities;
|
|
96
435
|
if (!raw || typeof raw !== "object") {
|
|
@@ -140,16 +479,6 @@ function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
|
140
479
|
};
|
|
141
480
|
}
|
|
142
481
|
|
|
143
|
-
function getMediaCount(tweet: XurlMentionData) {
|
|
144
|
-
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
145
|
-
return urls.filter(
|
|
146
|
-
(url) =>
|
|
147
|
-
url &&
|
|
148
|
-
typeof url === "object" &&
|
|
149
|
-
typeof (url as Record<string, unknown>).media_key === "string",
|
|
150
|
-
).length;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
482
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
154
483
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
155
484
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
@@ -173,17 +502,24 @@ function mergeMentionsIntoLocalStore(
|
|
|
173
502
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
174
503
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
175
504
|
entities_json, media_json, quoted_tweet_id
|
|
176
|
-
) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?,
|
|
505
|
+
) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?, ?, null)
|
|
177
506
|
on conflict(id) do update set
|
|
178
507
|
account_id = tweets.account_id,
|
|
179
508
|
author_profile_id = excluded.author_profile_id,
|
|
180
|
-
kind = case
|
|
509
|
+
kind = case
|
|
510
|
+
when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
|
|
511
|
+
when excluded.kind in ('authored', 'home', 'mention') then excluded.kind
|
|
512
|
+
else excluded.kind
|
|
513
|
+
end,
|
|
181
514
|
text = excluded.text,
|
|
182
515
|
created_at = excluded.created_at,
|
|
183
516
|
like_count = excluded.like_count,
|
|
184
|
-
media_count = excluded.media_count,
|
|
517
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
185
518
|
entities_json = excluded.entities_json,
|
|
186
|
-
media_json =
|
|
519
|
+
media_json = case
|
|
520
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
521
|
+
else tweets.media_json
|
|
522
|
+
end,
|
|
187
523
|
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
188
524
|
bookmarked = max(tweets.bookmarked, excluded.bookmarked),
|
|
189
525
|
liked = max(tweets.liked, excluded.liked)
|
|
@@ -210,8 +546,9 @@ function mergeMentionsIntoLocalStore(
|
|
|
210
546
|
tweet.text,
|
|
211
547
|
tweet.created_at,
|
|
212
548
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
213
|
-
|
|
549
|
+
countTweetMedia(tweet),
|
|
214
550
|
JSON.stringify(toLocalEntities(tweet)),
|
|
551
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
215
552
|
);
|
|
216
553
|
upsertTweetAccountEdge(db, {
|
|
217
554
|
accountId,
|
|
@@ -271,6 +608,8 @@ function mergeMentionPayloads(
|
|
|
271
608
|
const seenTweetIds = new Set<string>();
|
|
272
609
|
const users: XurlMentionUser[] = [];
|
|
273
610
|
const seenUserIds = new Set<string>();
|
|
611
|
+
const media: XurlMediaItem[] = [];
|
|
612
|
+
const seenMediaKeys = new Set<string>();
|
|
274
613
|
|
|
275
614
|
for (const page of pages) {
|
|
276
615
|
for (const tweet of page.data) {
|
|
@@ -288,12 +627,24 @@ function mergeMentionPayloads(
|
|
|
288
627
|
seenUserIds.add(user.id);
|
|
289
628
|
users.push(user);
|
|
290
629
|
}
|
|
630
|
+
|
|
631
|
+
for (const item of page.includes?.media ?? []) {
|
|
632
|
+
if (seenMediaKeys.has(item.media_key)) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
seenMediaKeys.add(item.media_key);
|
|
636
|
+
media.push(item);
|
|
637
|
+
}
|
|
291
638
|
}
|
|
292
639
|
|
|
293
640
|
const lastMeta = pages.at(-1)?.meta;
|
|
641
|
+
const includes = {
|
|
642
|
+
...(users.length > 0 ? { users } : {}),
|
|
643
|
+
...(media.length > 0 ? { media } : {}),
|
|
644
|
+
};
|
|
294
645
|
return {
|
|
295
646
|
data: tweets,
|
|
296
|
-
includes: users.length > 0
|
|
647
|
+
includes: users.length > 0 || media.length > 0 ? includes : undefined,
|
|
297
648
|
meta: {
|
|
298
649
|
...lastMeta,
|
|
299
650
|
result_count: tweets.length,
|
|
@@ -309,11 +660,17 @@ async function fetchMentionsViaXurl({
|
|
|
309
660
|
limit,
|
|
310
661
|
all,
|
|
311
662
|
parsedMaxPages,
|
|
663
|
+
sinceId,
|
|
664
|
+
startPaginationToken,
|
|
665
|
+
startTime,
|
|
312
666
|
}: {
|
|
313
667
|
resolvedAccount: ReturnType<typeof resolveAccount>;
|
|
314
668
|
limit: number;
|
|
315
669
|
all: boolean;
|
|
316
670
|
parsedMaxPages: number | null;
|
|
671
|
+
sinceId?: string;
|
|
672
|
+
startPaginationToken?: string;
|
|
673
|
+
startTime?: string;
|
|
317
674
|
}) {
|
|
318
675
|
const [accountUser] = await lookupUsersByHandles([resolvedAccount.username]);
|
|
319
676
|
if (!accountUser?.id) {
|
|
@@ -323,7 +680,7 @@ async function fetchMentionsViaXurl({
|
|
|
323
680
|
}
|
|
324
681
|
|
|
325
682
|
const pages: XurlMentionsResponse[] = [];
|
|
326
|
-
let nextToken: string | undefined;
|
|
683
|
+
let nextToken: string | undefined = startPaginationToken;
|
|
327
684
|
let pageCount = 0;
|
|
328
685
|
do {
|
|
329
686
|
const payload = await listMentionsViaXurl({
|
|
@@ -331,6 +688,8 @@ async function fetchMentionsViaXurl({
|
|
|
331
688
|
username: resolvedAccount.username,
|
|
332
689
|
userId: String(accountUser.id),
|
|
333
690
|
paginationToken: nextToken,
|
|
691
|
+
...(sinceId ? { sinceId } : {}),
|
|
692
|
+
...(startTime ? { startTime } : {}),
|
|
334
693
|
});
|
|
335
694
|
pages.push(payload);
|
|
336
695
|
const metaNextToken =
|
|
@@ -348,6 +707,248 @@ async function fetchMentionsViaXurl({
|
|
|
348
707
|
return mergeMentionPayloads(pages);
|
|
349
708
|
}
|
|
350
709
|
|
|
710
|
+
async function fetchMentionsViaBird({ limit }: { limit: number }) {
|
|
711
|
+
return listMentionsViaBird({ maxResults: limit });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isMaxPagesPartial({
|
|
715
|
+
payload,
|
|
716
|
+
maxPages,
|
|
717
|
+
}: {
|
|
718
|
+
payload: XurlMentionsResponse;
|
|
719
|
+
maxPages: number | null;
|
|
720
|
+
}) {
|
|
721
|
+
return (
|
|
722
|
+
maxPages !== null &&
|
|
723
|
+
typeof payload.meta?.next_token === "string" &&
|
|
724
|
+
payload.meta.next_token.length > 0
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export async function syncMentions({
|
|
729
|
+
account,
|
|
730
|
+
mode,
|
|
731
|
+
limit = 20,
|
|
732
|
+
maxPages,
|
|
733
|
+
refresh = false,
|
|
734
|
+
cacheTtlMs,
|
|
735
|
+
sinceId,
|
|
736
|
+
startTime,
|
|
737
|
+
}: {
|
|
738
|
+
account?: string;
|
|
739
|
+
mode?: string;
|
|
740
|
+
limit?: number;
|
|
741
|
+
maxPages?: number;
|
|
742
|
+
refresh?: boolean;
|
|
743
|
+
cacheTtlMs?: number;
|
|
744
|
+
sinceId?: string;
|
|
745
|
+
startTime?: string;
|
|
746
|
+
}) {
|
|
747
|
+
const parsedMode = parseSyncMode(mode);
|
|
748
|
+
const explicitSinceId = sinceId?.trim() || undefined;
|
|
749
|
+
const explicitStartTime = startTime?.trim() || undefined;
|
|
750
|
+
if (parsedMode === "bird" && (explicitSinceId || explicitStartTime)) {
|
|
751
|
+
throw new Error("bird mode does not support --since-id or --start-time");
|
|
752
|
+
}
|
|
753
|
+
if (parsedMode === "xurl") {
|
|
754
|
+
assertXurlLimit(limit);
|
|
755
|
+
} else {
|
|
756
|
+
assertBirdLimit(limit);
|
|
757
|
+
}
|
|
758
|
+
const parsedMaxPages = parseMaxPages(maxPages);
|
|
759
|
+
const fetchAll = parsedMode === "xurl" && parsedMaxPages !== null;
|
|
760
|
+
const db = getNativeDb();
|
|
761
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
762
|
+
const cursorShape: MentionScanShape = {
|
|
763
|
+
endpoint: "mentions",
|
|
764
|
+
mode: parsedMode,
|
|
765
|
+
accountId: resolvedAccount.accountId,
|
|
766
|
+
pageSize: limit,
|
|
767
|
+
boundary: getMentionCursorBoundary({
|
|
768
|
+
explicitSinceId,
|
|
769
|
+
explicitStartTime,
|
|
770
|
+
}),
|
|
771
|
+
};
|
|
772
|
+
const cursorKey = getMentionCursorKey(cursorShape);
|
|
773
|
+
const legacyCursorKeys = getLegacyMentionCursorKeys(cursorShape);
|
|
774
|
+
const cursor =
|
|
775
|
+
parsedMode === "xurl"
|
|
776
|
+
? readMentionCursor({
|
|
777
|
+
db,
|
|
778
|
+
shape: cursorShape,
|
|
779
|
+
cursorKey,
|
|
780
|
+
legacyCursorKeys,
|
|
781
|
+
})
|
|
782
|
+
: undefined;
|
|
783
|
+
const startPaginationToken = cursor?.token;
|
|
784
|
+
const cursorSinceId =
|
|
785
|
+
cursor?.boundary?.kind === "since" ? cursor.boundary.sinceId : undefined;
|
|
786
|
+
const cursorStartTime =
|
|
787
|
+
cursor?.boundary?.kind === "start" ? cursor.boundary.startTime : undefined;
|
|
788
|
+
const committedSinceId =
|
|
789
|
+
parsedMode === "xurl" &&
|
|
790
|
+
cursorShape.boundary.kind === "auto" &&
|
|
791
|
+
!startPaginationToken
|
|
792
|
+
? readMentionHighWaterId(db, parsedMode, resolvedAccount.accountId)
|
|
793
|
+
: undefined;
|
|
794
|
+
const seededSinceId =
|
|
795
|
+
parsedMode === "xurl" &&
|
|
796
|
+
!explicitSinceId &&
|
|
797
|
+
!explicitStartTime &&
|
|
798
|
+
!startPaginationToken
|
|
799
|
+
? (committedSinceId ??
|
|
800
|
+
findNewestArchiveMentionId(db, resolvedAccount.accountId))
|
|
801
|
+
: undefined;
|
|
802
|
+
const resolvedSinceId = startPaginationToken
|
|
803
|
+
? cursorSinceId
|
|
804
|
+
: (explicitSinceId ?? seededSinceId);
|
|
805
|
+
const resolvedStartTime = startPaginationToken
|
|
806
|
+
? cursorStartTime
|
|
807
|
+
: !resolvedSinceId
|
|
808
|
+
? explicitStartTime
|
|
809
|
+
: undefined;
|
|
810
|
+
const resultShape: MentionScanShape = {
|
|
811
|
+
endpoint: "mentions",
|
|
812
|
+
mode: parsedMode,
|
|
813
|
+
accountId: resolvedAccount.accountId,
|
|
814
|
+
pageSize: limit,
|
|
815
|
+
boundary: getMentionRequestBoundary({
|
|
816
|
+
sinceId: resolvedSinceId,
|
|
817
|
+
startTime: resolvedStartTime,
|
|
818
|
+
}),
|
|
819
|
+
};
|
|
820
|
+
const resultCacheKey = getMentionResultCacheKey({
|
|
821
|
+
shape: resultShape,
|
|
822
|
+
all: fetchAll,
|
|
823
|
+
maxPages: parsedMaxPages,
|
|
824
|
+
});
|
|
825
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
826
|
+
const cached = startPaginationToken
|
|
827
|
+
? null
|
|
828
|
+
: readSyncCache<XurlMentionsResponse>(resultCacheKey, db);
|
|
829
|
+
const cachedPaginationToken = getCachedPaginationToken(cached);
|
|
830
|
+
const cacheAgeMs = cached
|
|
831
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
832
|
+
: Number.POSITIVE_INFINITY;
|
|
833
|
+
|
|
834
|
+
if (
|
|
835
|
+
!startPaginationToken &&
|
|
836
|
+
!cachedPaginationToken &&
|
|
837
|
+
!refresh &&
|
|
838
|
+
cached &&
|
|
839
|
+
cacheAgeMs <= ttlMs
|
|
840
|
+
) {
|
|
841
|
+
mergeMentionsIntoLocalStore(
|
|
842
|
+
db,
|
|
843
|
+
resolvedAccount.accountId,
|
|
844
|
+
cached.value,
|
|
845
|
+
parsedMode,
|
|
846
|
+
);
|
|
847
|
+
return {
|
|
848
|
+
ok: true,
|
|
849
|
+
source: "cache",
|
|
850
|
+
kind: "mentions",
|
|
851
|
+
accountId: resolvedAccount.accountId,
|
|
852
|
+
count: cached.value.data.length,
|
|
853
|
+
partial: isMaxPagesPartial({
|
|
854
|
+
payload: cached.value,
|
|
855
|
+
maxPages: parsedMaxPages,
|
|
856
|
+
}),
|
|
857
|
+
payload: cached.value,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (
|
|
862
|
+
parsedMode === "xurl" &&
|
|
863
|
+
!explicitSinceId &&
|
|
864
|
+
!explicitStartTime &&
|
|
865
|
+
!startPaginationToken &&
|
|
866
|
+
!seededSinceId
|
|
867
|
+
) {
|
|
868
|
+
console.error(
|
|
869
|
+
"No local mention baseline found; syncing mentions from the newest page backwards.",
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const payload =
|
|
874
|
+
parsedMode === "bird"
|
|
875
|
+
? await fetchMentionsViaBird({ limit })
|
|
876
|
+
: await fetchMentionsViaXurl({
|
|
877
|
+
resolvedAccount,
|
|
878
|
+
limit,
|
|
879
|
+
all: fetchAll,
|
|
880
|
+
parsedMaxPages,
|
|
881
|
+
sinceId: resolvedSinceId,
|
|
882
|
+
startPaginationToken,
|
|
883
|
+
startTime: resolvedStartTime,
|
|
884
|
+
});
|
|
885
|
+
mergeMentionsIntoLocalStore(
|
|
886
|
+
db,
|
|
887
|
+
resolvedAccount.accountId,
|
|
888
|
+
payload,
|
|
889
|
+
parsedMode,
|
|
890
|
+
);
|
|
891
|
+
const payloadPaginationToken = getCachedPaginationToken({ value: payload });
|
|
892
|
+
if (parsedMode === "xurl") {
|
|
893
|
+
if (payloadPaginationToken) {
|
|
894
|
+
writeSyncCache(
|
|
895
|
+
cursorKey,
|
|
896
|
+
addMentionCursorState(
|
|
897
|
+
payload,
|
|
898
|
+
getMentionRequestBoundary({
|
|
899
|
+
sinceId: resolvedSinceId,
|
|
900
|
+
startTime: resolvedStartTime,
|
|
901
|
+
}),
|
|
902
|
+
maxNumericTweetId(resolvedSinceId, getNewestMentionId(payload)),
|
|
903
|
+
),
|
|
904
|
+
db,
|
|
905
|
+
);
|
|
906
|
+
deleteSyncCache(resultCacheKey, db);
|
|
907
|
+
deleteSyncCache(
|
|
908
|
+
getMentionResultCacheKey({
|
|
909
|
+
shape: cursorShape,
|
|
910
|
+
all: fetchAll,
|
|
911
|
+
maxPages: parsedMaxPages,
|
|
912
|
+
}),
|
|
913
|
+
db,
|
|
914
|
+
);
|
|
915
|
+
for (const legacyKey of cursor?.legacyKeys ?? []) {
|
|
916
|
+
deleteSyncCache(legacyKey, db);
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
deleteSyncCache(cursorKey, db);
|
|
920
|
+
for (const legacyKey of legacyCursorKeys) {
|
|
921
|
+
deleteSyncCache(legacyKey, db);
|
|
922
|
+
}
|
|
923
|
+
if (cursorShape.boundary.kind === "auto") {
|
|
924
|
+
writeMentionHighWaterId(
|
|
925
|
+
db,
|
|
926
|
+
parsedMode,
|
|
927
|
+
resolvedAccount.accountId,
|
|
928
|
+
maxNumericTweetId(
|
|
929
|
+
resolvedSinceId,
|
|
930
|
+
cursor?.pendingNewestId,
|
|
931
|
+
getNewestMentionId(payload),
|
|
932
|
+
),
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!payloadPaginationToken && !startPaginationToken) {
|
|
938
|
+
writeSyncCache(resultCacheKey, payload, db);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
ok: true,
|
|
943
|
+
source: parsedMode,
|
|
944
|
+
kind: "mentions",
|
|
945
|
+
accountId: resolvedAccount.accountId,
|
|
946
|
+
count: payload.data.length,
|
|
947
|
+
partial: isMaxPagesPartial({ payload, maxPages: parsedMaxPages }),
|
|
948
|
+
payload,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
351
952
|
async function exportMentionsViaCachedLiveSource({
|
|
352
953
|
mode,
|
|
353
954
|
account,
|
|
@@ -380,11 +981,14 @@ async function exportMentionsViaCachedLiveSource({
|
|
|
380
981
|
const db = getNativeDb();
|
|
381
982
|
const resolvedAccount = resolveAccount(db, account);
|
|
382
983
|
const cacheKey = getMentionsFetchModeKey({
|
|
984
|
+
scope: "export",
|
|
383
985
|
mode,
|
|
384
986
|
accountId: resolvedAccount.accountId,
|
|
385
987
|
pageSize: limit,
|
|
386
988
|
all: fetchAll,
|
|
387
989
|
maxPages: parsedMaxPages,
|
|
990
|
+
sinceId: null,
|
|
991
|
+
startTime: null,
|
|
388
992
|
});
|
|
389
993
|
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
390
994
|
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|