birdclaw 0.4.1 → 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 +33 -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 +450 -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 +1565 -65
- package/src/lib/authored-live.ts +1074 -0
- package/src/lib/backup.ts +316 -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 +191 -4
- package/src/lib/follow-graph.ts +1053 -0
- package/src/lib/link-index.ts +11 -98
- 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 +185 -2
- package/src/lib/ui.ts +375 -147
- package/src/lib/url-expansion-store.ts +110 -0
- package/src/lib/url-expansion.ts +20 -3
- 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
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
import type { Database } from "./sqlite";
|
|
2
2
|
import { listThreadViaBird } from "./bird";
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
|
-
import
|
|
4
|
+
import { buildMediaJsonFromIncludes, countTweetMedia } from "./media-includes";
|
|
5
|
+
import type {
|
|
6
|
+
XurlMentionData,
|
|
7
|
+
XurlMentionsResponse,
|
|
8
|
+
XurlMentionUser,
|
|
9
|
+
XurlMediaItem,
|
|
10
|
+
XurlTweetsResponse,
|
|
11
|
+
} from "./types";
|
|
12
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
5
13
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
14
|
+
import { getTweetById, searchRecentByConversationId } from "./xurl";
|
|
6
15
|
|
|
7
16
|
const DEFAULT_LIMIT = 30;
|
|
8
17
|
const DEFAULT_DELAY_MS = 1500;
|
|
9
18
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
|
+
const DEFAULT_MODE = "bird";
|
|
20
|
+
const DEFAULT_FALLBACK_DEPTH = 12;
|
|
21
|
+
const MAX_XURL_SEARCH_RESULTS = 100;
|
|
22
|
+
|
|
23
|
+
export type MentionThreadsMode = "bird" | "xurl";
|
|
24
|
+
|
|
25
|
+
interface LocalMention {
|
|
26
|
+
id: string;
|
|
27
|
+
replyToId?: string;
|
|
28
|
+
conversationId?: string;
|
|
29
|
+
rawTweet?: XurlMentionData;
|
|
30
|
+
}
|
|
10
31
|
|
|
11
32
|
function assertPositiveInteger(value: number, name: string) {
|
|
12
33
|
if (!Number.isFinite(value) || value < 1) {
|
|
@@ -25,20 +46,30 @@ function parseNonNegativeInteger(value: number | undefined, name: string) {
|
|
|
25
46
|
return Math.floor(value);
|
|
26
47
|
}
|
|
27
48
|
|
|
49
|
+
function parseMode(value: string | undefined): MentionThreadsMode {
|
|
50
|
+
const mode = value ?? DEFAULT_MODE;
|
|
51
|
+
if (mode !== "bird" && mode !== "xurl") {
|
|
52
|
+
throw new Error("--mode must be bird or xurl");
|
|
53
|
+
}
|
|
54
|
+
return mode;
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
function sleep(ms: number) {
|
|
29
58
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
59
|
}
|
|
31
60
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
function getRemainingThreadTimeoutMs(
|
|
62
|
+
deadlineMs: number,
|
|
63
|
+
originalTimeoutMs: number,
|
|
64
|
+
) {
|
|
65
|
+
const remainingMs = deadlineMs - Date.now();
|
|
66
|
+
if (remainingMs <= 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`xurl thread timed out after ${String(originalTimeoutMs)}ms`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return remainingMs;
|
|
40
72
|
}
|
|
41
|
-
|
|
42
73
|
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
43
74
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
44
75
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
@@ -73,39 +104,159 @@ function resolveAccount(db: Database, accountId?: string) {
|
|
|
73
104
|
};
|
|
74
105
|
}
|
|
75
106
|
|
|
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
107
|
function getReplyToId(tweet: XurlMentionData) {
|
|
93
108
|
return tweet.referenced_tweets?.find((entry) => entry.type === "replied_to")
|
|
94
109
|
?.id;
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
function mergePayloads(pages: XurlTweetsResponse[]): XurlMentionsResponse {
|
|
113
|
+
const tweets: XurlMentionData[] = [];
|
|
114
|
+
const seenTweetIds = new Set<string>();
|
|
115
|
+
const users: XurlMentionUser[] = [];
|
|
116
|
+
const seenUserIds = new Set<string>();
|
|
117
|
+
const media: XurlMediaItem[] = [];
|
|
118
|
+
const seenMediaKeys = new Set<string>();
|
|
119
|
+
|
|
120
|
+
for (const page of pages) {
|
|
121
|
+
for (const tweet of page.data) {
|
|
122
|
+
if (seenTweetIds.has(tweet.id)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
seenTweetIds.add(tweet.id);
|
|
126
|
+
tweets.push(tweet);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const user of page.includes?.users ?? []) {
|
|
130
|
+
if (seenUserIds.has(user.id)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
seenUserIds.add(user.id);
|
|
134
|
+
users.push(user);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const item of page.includes?.media ?? []) {
|
|
138
|
+
if (seenMediaKeys.has(item.media_key)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
seenMediaKeys.add(item.media_key);
|
|
142
|
+
media.push(item);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lastMeta = pages.at(-1)?.meta;
|
|
147
|
+
return {
|
|
148
|
+
data: tweets,
|
|
149
|
+
includes:
|
|
150
|
+
users.length > 0 || media.length > 0
|
|
151
|
+
? {
|
|
152
|
+
...(users.length > 0 ? { users } : {}),
|
|
153
|
+
...(media.length > 0 ? { media } : {}),
|
|
154
|
+
}
|
|
155
|
+
: undefined,
|
|
156
|
+
meta: {
|
|
157
|
+
...lastMeta,
|
|
158
|
+
result_count: tweets.length,
|
|
159
|
+
page_count: pages.length,
|
|
160
|
+
next_token:
|
|
161
|
+
typeof lastMeta?.next_token === "string" ? lastMeta.next_token : null,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseRawTweet(value: string | null | undefined) {
|
|
167
|
+
if (!value || value === "{}" || value === "null") {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(value) as unknown;
|
|
173
|
+
if (parsed && typeof parsed === "object") {
|
|
174
|
+
return parsed as XurlMentionData;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function listRecentMentions(
|
|
184
|
+
db: Database,
|
|
185
|
+
accountId: string,
|
|
186
|
+
limit: number,
|
|
187
|
+
): LocalMention[] {
|
|
188
|
+
const rows = db
|
|
189
|
+
.prepare(
|
|
190
|
+
`
|
|
191
|
+
with local_mentions as (
|
|
192
|
+
select
|
|
193
|
+
t.id,
|
|
194
|
+
t.created_at as createdAt,
|
|
195
|
+
t.reply_to_id as replyToId,
|
|
196
|
+
edge.raw_json as rawJson
|
|
197
|
+
from tweet_account_edges edge
|
|
198
|
+
join tweets t on t.id = edge.tweet_id
|
|
199
|
+
where edge.kind = 'mention' and edge.account_id = ?
|
|
200
|
+
union all
|
|
201
|
+
select
|
|
202
|
+
t.id,
|
|
203
|
+
t.created_at as createdAt,
|
|
204
|
+
t.reply_to_id as replyToId,
|
|
205
|
+
'{}' as rawJson
|
|
206
|
+
from tweets t
|
|
207
|
+
where t.kind = 'mention' and t.account_id = ?
|
|
208
|
+
and not exists (
|
|
209
|
+
select 1
|
|
210
|
+
from tweet_account_edges edge
|
|
211
|
+
where edge.account_id = t.account_id
|
|
212
|
+
and edge.tweet_id = t.id
|
|
213
|
+
and edge.kind = 'mention'
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
select id, createdAt, replyToId, rawJson
|
|
217
|
+
from local_mentions
|
|
218
|
+
order by createdAt desc
|
|
219
|
+
limit ?
|
|
220
|
+
`,
|
|
221
|
+
)
|
|
222
|
+
.all(accountId, accountId, limit) as Array<{
|
|
223
|
+
id: string;
|
|
224
|
+
createdAt: string;
|
|
225
|
+
replyToId: string | null;
|
|
226
|
+
rawJson: string | null;
|
|
227
|
+
}>;
|
|
228
|
+
|
|
229
|
+
return rows.map((row) => {
|
|
230
|
+
const rawTweet = parseRawTweet(row.rawJson);
|
|
231
|
+
return {
|
|
232
|
+
id: row.id,
|
|
233
|
+
replyToId:
|
|
234
|
+
row.replyToId ?? (rawTweet ? getReplyToId(rawTweet) : undefined),
|
|
235
|
+
conversationId:
|
|
236
|
+
typeof rawTweet?.conversation_id === "string"
|
|
237
|
+
? rawTweet.conversation_id
|
|
238
|
+
: undefined,
|
|
239
|
+
rawTweet,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
97
244
|
function mergeMentionThreadIntoLocalStore({
|
|
98
245
|
db,
|
|
99
246
|
accountId,
|
|
100
247
|
accountHandle,
|
|
101
248
|
mentionIds,
|
|
102
249
|
payload,
|
|
250
|
+
source = "bird",
|
|
251
|
+
writeThreadContextEdges = false,
|
|
103
252
|
}: {
|
|
104
253
|
db: Database;
|
|
105
254
|
accountId: string;
|
|
106
255
|
accountHandle: string;
|
|
107
256
|
mentionIds: Set<string>;
|
|
108
257
|
payload: XurlMentionsResponse;
|
|
258
|
+
source?: "bird" | "xurl";
|
|
259
|
+
writeThreadContextEdges?: boolean;
|
|
109
260
|
}) {
|
|
110
261
|
const usersById = new Map(
|
|
111
262
|
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
@@ -116,13 +267,13 @@ function mergeMentionThreadIntoLocalStore({
|
|
|
116
267
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
117
268
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
118
269
|
entities_json, media_json, quoted_tweet_id
|
|
119
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?,
|
|
270
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, null)
|
|
120
271
|
on conflict(id) do update set
|
|
121
272
|
account_id = excluded.account_id,
|
|
122
273
|
author_profile_id = excluded.author_profile_id,
|
|
123
274
|
kind = case
|
|
124
|
-
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
125
|
-
when excluded.kind in ('home', 'mention') then excluded.kind
|
|
275
|
+
when tweets.kind in ('authored', 'home', 'mention') then tweets.kind
|
|
276
|
+
when excluded.kind in ('authored', 'home', 'mention') then excluded.kind
|
|
126
277
|
else coalesce(nullif(tweets.kind, ''), excluded.kind)
|
|
127
278
|
end,
|
|
128
279
|
text = excluded.text,
|
|
@@ -130,15 +281,19 @@ function mergeMentionThreadIntoLocalStore({
|
|
|
130
281
|
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
131
282
|
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
132
283
|
like_count = excluded.like_count,
|
|
133
|
-
media_count = excluded.media_count,
|
|
284
|
+
media_count = max(tweets.media_count, excluded.media_count),
|
|
134
285
|
entities_json = excluded.entities_json,
|
|
135
|
-
media_json =
|
|
286
|
+
media_json = case
|
|
287
|
+
when excluded.media_json not in ('', '[]', 'null') then excluded.media_json
|
|
288
|
+
else tweets.media_json
|
|
289
|
+
end,
|
|
136
290
|
bookmarked = tweets.bookmarked,
|
|
137
291
|
liked = tweets.liked
|
|
138
292
|
`,
|
|
139
293
|
);
|
|
140
294
|
|
|
141
295
|
db.transaction(() => {
|
|
296
|
+
const seenAt = new Date().toISOString();
|
|
142
297
|
for (const tweet of payload.data) {
|
|
143
298
|
const author =
|
|
144
299
|
usersById.get(tweet.author_id) ??
|
|
@@ -167,16 +322,296 @@ function mergeMentionThreadIntoLocalStore({
|
|
|
167
322
|
replyToId ? 1 : 0,
|
|
168
323
|
replyToId ?? null,
|
|
169
324
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
170
|
-
|
|
325
|
+
countTweetMedia(tweet),
|
|
171
326
|
JSON.stringify(tweet.entities ?? {}),
|
|
327
|
+
buildMediaJsonFromIncludes(tweet, payload.includes?.media),
|
|
172
328
|
);
|
|
329
|
+
if (writeThreadContextEdges) {
|
|
330
|
+
upsertTweetAccountEdge(db, {
|
|
331
|
+
accountId,
|
|
332
|
+
tweetId: tweet.id,
|
|
333
|
+
kind: "thread_context",
|
|
334
|
+
source,
|
|
335
|
+
seenAt,
|
|
336
|
+
rawJson: JSON.stringify(tweet),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
173
339
|
replaceTweetFts(db, tweet.id, tweet.text);
|
|
174
340
|
}
|
|
175
341
|
})();
|
|
176
342
|
}
|
|
177
343
|
|
|
344
|
+
async function fetchConversationViaRecentSearch({
|
|
345
|
+
conversationId,
|
|
346
|
+
all,
|
|
347
|
+
maxPages,
|
|
348
|
+
timeoutMs,
|
|
349
|
+
deadlineMs,
|
|
350
|
+
}: {
|
|
351
|
+
conversationId: string;
|
|
352
|
+
all: boolean;
|
|
353
|
+
maxPages?: number;
|
|
354
|
+
timeoutMs: number;
|
|
355
|
+
deadlineMs: number;
|
|
356
|
+
}) {
|
|
357
|
+
const pages: XurlTweetsResponse[] = [];
|
|
358
|
+
let nextToken: string | undefined;
|
|
359
|
+
let pageCount = 0;
|
|
360
|
+
|
|
361
|
+
do {
|
|
362
|
+
const payload = await searchRecentByConversationId(conversationId, {
|
|
363
|
+
maxResults: MAX_XURL_SEARCH_RESULTS,
|
|
364
|
+
paginationToken: nextToken,
|
|
365
|
+
timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
|
|
366
|
+
});
|
|
367
|
+
pages.push(payload);
|
|
368
|
+
nextToken =
|
|
369
|
+
typeof payload.meta?.next_token === "string"
|
|
370
|
+
? payload.meta.next_token
|
|
371
|
+
: undefined;
|
|
372
|
+
pageCount += 1;
|
|
373
|
+
} while (
|
|
374
|
+
(all || maxPages !== undefined) &&
|
|
375
|
+
nextToken &&
|
|
376
|
+
(maxPages === undefined || pageCount < maxPages)
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const payload = mergePayloads(pages);
|
|
380
|
+
const paginationRequested = all || maxPages !== undefined;
|
|
381
|
+
return {
|
|
382
|
+
payload,
|
|
383
|
+
pages: pageCount,
|
|
384
|
+
truncated: paginationRequested && Boolean(nextToken),
|
|
385
|
+
generalReadTweets: payload.data.length,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function fetchParentChainViaXurl({
|
|
390
|
+
mention,
|
|
391
|
+
maxDepth,
|
|
392
|
+
timeoutMs,
|
|
393
|
+
deadlineMs,
|
|
394
|
+
}: {
|
|
395
|
+
mention: LocalMention;
|
|
396
|
+
maxDepth: number;
|
|
397
|
+
timeoutMs: number;
|
|
398
|
+
deadlineMs: number;
|
|
399
|
+
}) {
|
|
400
|
+
const pages: XurlTweetsResponse[] = [];
|
|
401
|
+
const warnings: string[] = [];
|
|
402
|
+
const seenTweetIds = new Set([mention.id]);
|
|
403
|
+
let nextParentId = mention.replyToId;
|
|
404
|
+
let fallbackDepth = 0;
|
|
405
|
+
let generalReadTweets = 0;
|
|
406
|
+
|
|
407
|
+
const rawAnchorPayload =
|
|
408
|
+
mention.rawTweet && mention.rawTweet.id === mention.id
|
|
409
|
+
? ({ data: [mention.rawTweet] } satisfies XurlTweetsResponse)
|
|
410
|
+
: undefined;
|
|
411
|
+
let shouldUseRawAnchor = Boolean(rawAnchorPayload);
|
|
412
|
+
|
|
413
|
+
if (!nextParentId) {
|
|
414
|
+
const anchorPayload = await getTweetById(mention.id, {
|
|
415
|
+
timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
|
|
416
|
+
});
|
|
417
|
+
pages.push(anchorPayload);
|
|
418
|
+
generalReadTweets += anchorPayload.data.length;
|
|
419
|
+
const anchorTweet = anchorPayload.data[0];
|
|
420
|
+
if (anchorTweet) {
|
|
421
|
+
shouldUseRawAnchor = false;
|
|
422
|
+
seenTweetIds.add(anchorTweet.id);
|
|
423
|
+
nextParentId = anchorTweet.in_reply_to_user_id
|
|
424
|
+
? getReplyToId(anchorTweet)
|
|
425
|
+
: undefined;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (shouldUseRawAnchor && rawAnchorPayload) {
|
|
430
|
+
pages.unshift(rawAnchorPayload);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
while (nextParentId) {
|
|
434
|
+
if (fallbackDepth >= maxDepth) {
|
|
435
|
+
warnings.push(
|
|
436
|
+
`fallback parent-chain depth cap reached for ${mention.id} after ${maxDepth} hops`,
|
|
437
|
+
);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
if (seenTweetIds.has(nextParentId)) {
|
|
441
|
+
warnings.push(
|
|
442
|
+
`fallback parent-chain cycle detected for ${mention.id} at ${nextParentId}`,
|
|
443
|
+
);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
fallbackDepth += 1;
|
|
448
|
+
const parentPayload = await getTweetById(nextParentId, {
|
|
449
|
+
timeoutMs: getRemainingThreadTimeoutMs(deadlineMs, timeoutMs),
|
|
450
|
+
});
|
|
451
|
+
pages.push(parentPayload);
|
|
452
|
+
generalReadTweets += parentPayload.data.length;
|
|
453
|
+
const parentTweet = parentPayload.data[0];
|
|
454
|
+
if (!parentTweet) {
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
seenTweetIds.add(parentTweet.id);
|
|
458
|
+
nextParentId = parentTweet.in_reply_to_user_id
|
|
459
|
+
? getReplyToId(parentTweet)
|
|
460
|
+
: undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const payload = mergePayloads(pages);
|
|
464
|
+
return {
|
|
465
|
+
payload,
|
|
466
|
+
fallbackDepth,
|
|
467
|
+
warnings,
|
|
468
|
+
generalReadTweets,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function findMissingAncestorId(
|
|
473
|
+
mention: LocalMention,
|
|
474
|
+
payload: XurlMentionsResponse,
|
|
475
|
+
) {
|
|
476
|
+
const tweetsById = new Map(payload.data.map((tweet) => [tweet.id, tweet]));
|
|
477
|
+
const seenTweetIds = new Set<string>([mention.id]);
|
|
478
|
+
const anchorTweet = tweetsById.get(mention.id) ?? mention.rawTweet;
|
|
479
|
+
let nextParentId = anchorTweet
|
|
480
|
+
? getReplyToId(anchorTweet)
|
|
481
|
+
: mention.replyToId;
|
|
482
|
+
|
|
483
|
+
while (nextParentId) {
|
|
484
|
+
if (seenTweetIds.has(nextParentId)) {
|
|
485
|
+
return undefined;
|
|
486
|
+
}
|
|
487
|
+
const parentTweet = tweetsById.get(nextParentId);
|
|
488
|
+
if (!parentTweet) {
|
|
489
|
+
return nextParentId;
|
|
490
|
+
}
|
|
491
|
+
seenTweetIds.add(parentTweet.id);
|
|
492
|
+
nextParentId = parentTweet.in_reply_to_user_id
|
|
493
|
+
? getReplyToId(parentTweet)
|
|
494
|
+
: undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
mention.conversationId &&
|
|
499
|
+
mention.conversationId !== mention.id &&
|
|
500
|
+
!tweetsById.has(mention.conversationId)
|
|
501
|
+
) {
|
|
502
|
+
return mention.conversationId;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function fetchThreadContextViaXurl({
|
|
509
|
+
mention,
|
|
510
|
+
all,
|
|
511
|
+
maxPages,
|
|
512
|
+
maxFallbackDepth,
|
|
513
|
+
timeoutMs,
|
|
514
|
+
}: {
|
|
515
|
+
mention: LocalMention;
|
|
516
|
+
all: boolean;
|
|
517
|
+
maxPages?: number;
|
|
518
|
+
maxFallbackDepth: number;
|
|
519
|
+
timeoutMs: number;
|
|
520
|
+
}) {
|
|
521
|
+
const deadlineMs = Date.now() + timeoutMs;
|
|
522
|
+
if (!mention.conversationId) {
|
|
523
|
+
if (mention.replyToId) {
|
|
524
|
+
const fallback = await fetchParentChainViaXurl({
|
|
525
|
+
mention,
|
|
526
|
+
maxDepth: maxFallbackDepth,
|
|
527
|
+
timeoutMs,
|
|
528
|
+
deadlineMs,
|
|
529
|
+
});
|
|
530
|
+
return {
|
|
531
|
+
strategy: "parent_walk" as const,
|
|
532
|
+
pages: 0,
|
|
533
|
+
truncated: false,
|
|
534
|
+
payload: fallback.payload,
|
|
535
|
+
fallbackDepth: fallback.fallbackDepth,
|
|
536
|
+
generalReadTweets: fallback.generalReadTweets,
|
|
537
|
+
warnings: [
|
|
538
|
+
`missing conversation_id for ${mention.id}; used parent walk`,
|
|
539
|
+
...fallback.warnings,
|
|
540
|
+
],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
strategy: "skipped:no_conversation_id" as const,
|
|
545
|
+
payload: { data: [] } satisfies XurlMentionsResponse,
|
|
546
|
+
pages: 0,
|
|
547
|
+
fallbackDepth: 0,
|
|
548
|
+
generalReadTweets: 0,
|
|
549
|
+
warnings: [`skipped ${mention.id}: missing conversation_id`],
|
|
550
|
+
truncated: false,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const search = await fetchConversationViaRecentSearch({
|
|
555
|
+
conversationId: mention.conversationId,
|
|
556
|
+
all,
|
|
557
|
+
maxPages,
|
|
558
|
+
timeoutMs,
|
|
559
|
+
deadlineMs,
|
|
560
|
+
});
|
|
561
|
+
if (search.payload.data.length > 0) {
|
|
562
|
+
const missingAncestorId = findMissingAncestorId(mention, search.payload);
|
|
563
|
+
if (missingAncestorId) {
|
|
564
|
+
const fallback = await fetchParentChainViaXurl({
|
|
565
|
+
mention,
|
|
566
|
+
maxDepth: maxFallbackDepth,
|
|
567
|
+
timeoutMs,
|
|
568
|
+
deadlineMs,
|
|
569
|
+
});
|
|
570
|
+
return {
|
|
571
|
+
strategy: "conversation_search+parent_walk" as const,
|
|
572
|
+
pages: search.pages,
|
|
573
|
+
truncated: search.truncated,
|
|
574
|
+
payload: mergePayloads([search.payload, fallback.payload]),
|
|
575
|
+
fallbackDepth: fallback.fallbackDepth,
|
|
576
|
+
generalReadTweets:
|
|
577
|
+
search.generalReadTweets + fallback.generalReadTweets,
|
|
578
|
+
warnings: [
|
|
579
|
+
`recent search missed ancestor ${missingAncestorId} for conversation ${mention.conversationId}; used parent walk`,
|
|
580
|
+
...fallback.warnings,
|
|
581
|
+
],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return {
|
|
585
|
+
strategy: "conversation_search" as const,
|
|
586
|
+
fallbackDepth: 0,
|
|
587
|
+
warnings: [] as string[],
|
|
588
|
+
...search,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const fallback = await fetchParentChainViaXurl({
|
|
593
|
+
mention,
|
|
594
|
+
maxDepth: maxFallbackDepth,
|
|
595
|
+
timeoutMs,
|
|
596
|
+
deadlineMs,
|
|
597
|
+
});
|
|
598
|
+
return {
|
|
599
|
+
strategy: "parent_walk" as const,
|
|
600
|
+
pages: search.pages,
|
|
601
|
+
truncated: search.truncated,
|
|
602
|
+
payload: fallback.payload,
|
|
603
|
+
fallbackDepth: fallback.fallbackDepth,
|
|
604
|
+
generalReadTweets: search.generalReadTweets + fallback.generalReadTweets,
|
|
605
|
+
warnings: [
|
|
606
|
+
`recent search returned no tweets for conversation ${mention.conversationId}; used parent walk`,
|
|
607
|
+
...fallback.warnings,
|
|
608
|
+
],
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
178
612
|
export async function syncMentionThreads({
|
|
179
613
|
account,
|
|
614
|
+
mode = DEFAULT_MODE,
|
|
180
615
|
limit = DEFAULT_LIMIT,
|
|
181
616
|
delayMs = DEFAULT_DELAY_MS,
|
|
182
617
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
@@ -184,84 +619,141 @@ export async function syncMentionThreads({
|
|
|
184
619
|
maxPages,
|
|
185
620
|
}: {
|
|
186
621
|
account?: string;
|
|
622
|
+
mode?: string;
|
|
187
623
|
limit?: number;
|
|
188
624
|
delayMs?: number;
|
|
189
625
|
timeoutMs?: number;
|
|
190
626
|
all?: boolean;
|
|
191
627
|
maxPages?: number;
|
|
192
628
|
}) {
|
|
629
|
+
const parsedMode = parseMode(mode);
|
|
193
630
|
const parsedLimit = assertPositiveInteger(limit, "--limit");
|
|
194
631
|
const parsedDelayMs = parseNonNegativeInteger(delayMs, "--delay-ms") ?? 0;
|
|
195
632
|
const parsedTimeoutMs = assertPositiveInteger(timeoutMs, "--timeout-ms");
|
|
196
633
|
const parsedMaxPages = parseNonNegativeInteger(maxPages, "--max-pages");
|
|
197
634
|
const db = getNativeDb();
|
|
198
635
|
const resolvedAccount = resolveAccount(db, account);
|
|
199
|
-
const
|
|
636
|
+
const mentions = listRecentMentions(
|
|
200
637
|
db,
|
|
201
638
|
resolvedAccount.accountId,
|
|
202
639
|
parsedLimit,
|
|
203
640
|
);
|
|
641
|
+
const mentionIds = mentions.map((mention) => mention.id);
|
|
204
642
|
const mentionIdSet = new Set(mentionIds);
|
|
205
643
|
const results: Array<{
|
|
206
644
|
tweetId: string;
|
|
645
|
+
conversationId?: string | null;
|
|
207
646
|
ok: boolean;
|
|
208
647
|
count: number;
|
|
648
|
+
strategy?: string;
|
|
649
|
+
pages?: number;
|
|
650
|
+
fallbackDepth?: number;
|
|
651
|
+
truncated?: boolean;
|
|
652
|
+
warnings?: string[];
|
|
209
653
|
error?: string;
|
|
210
654
|
}> = [];
|
|
211
655
|
let mergedTweets = 0;
|
|
656
|
+
let generalReadTweets = 0;
|
|
212
657
|
const uniqueTweetIds = new Set<string>();
|
|
658
|
+
const warnings: string[] = [];
|
|
213
659
|
|
|
214
|
-
for (const [index,
|
|
660
|
+
for (const [index, mention] of mentions.entries()) {
|
|
215
661
|
if (index > 0 && parsedDelayMs > 0) {
|
|
216
662
|
await sleep(parsedDelayMs);
|
|
217
663
|
}
|
|
218
664
|
try {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
665
|
+
const fetchResult =
|
|
666
|
+
parsedMode === "bird"
|
|
667
|
+
? {
|
|
668
|
+
strategy: "bird" as const,
|
|
669
|
+
payload: await listThreadViaBird({
|
|
670
|
+
tweetId: mention.id,
|
|
671
|
+
all,
|
|
672
|
+
maxPages: parsedMaxPages,
|
|
673
|
+
timeoutMs: parsedTimeoutMs,
|
|
674
|
+
}),
|
|
675
|
+
pages: undefined,
|
|
676
|
+
fallbackDepth: undefined,
|
|
677
|
+
generalReadTweets: 0,
|
|
678
|
+
truncated: undefined,
|
|
679
|
+
warnings: [] as string[],
|
|
680
|
+
}
|
|
681
|
+
: await fetchThreadContextViaXurl({
|
|
682
|
+
mention,
|
|
683
|
+
all,
|
|
684
|
+
maxPages: parsedMaxPages,
|
|
685
|
+
maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
|
|
686
|
+
timeoutMs: parsedTimeoutMs,
|
|
687
|
+
});
|
|
688
|
+
const { payload } = fetchResult;
|
|
225
689
|
mergeMentionThreadIntoLocalStore({
|
|
226
690
|
db,
|
|
227
691
|
accountId: resolvedAccount.accountId,
|
|
228
692
|
accountHandle: resolvedAccount.handle,
|
|
229
693
|
mentionIds: mentionIdSet,
|
|
230
694
|
payload,
|
|
695
|
+
source: parsedMode,
|
|
696
|
+
writeThreadContextEdges: parsedMode === "xurl",
|
|
231
697
|
});
|
|
232
698
|
for (const tweet of payload.data) {
|
|
233
699
|
uniqueTweetIds.add(tweet.id);
|
|
234
700
|
}
|
|
235
701
|
mergedTweets += payload.data.length;
|
|
236
|
-
|
|
702
|
+
generalReadTweets += fetchResult.generalReadTweets;
|
|
703
|
+
warnings.push(...fetchResult.warnings);
|
|
704
|
+
results.push({
|
|
705
|
+
tweetId: mention.id,
|
|
706
|
+
conversationId: mention.conversationId ?? null,
|
|
707
|
+
ok: true,
|
|
708
|
+
count: payload.data.length,
|
|
709
|
+
strategy: fetchResult.strategy,
|
|
710
|
+
pages: fetchResult.pages,
|
|
711
|
+
fallbackDepth: fetchResult.fallbackDepth,
|
|
712
|
+
truncated: fetchResult.truncated,
|
|
713
|
+
warnings:
|
|
714
|
+
fetchResult.warnings.length > 0 ? fetchResult.warnings : undefined,
|
|
715
|
+
});
|
|
237
716
|
} catch (error) {
|
|
238
717
|
results.push({
|
|
239
|
-
tweetId,
|
|
718
|
+
tweetId: mention.id,
|
|
719
|
+
conversationId: mention.conversationId ?? null,
|
|
240
720
|
ok: false,
|
|
241
721
|
count: 0,
|
|
722
|
+
strategy: parsedMode,
|
|
242
723
|
error: error instanceof Error ? error.message : String(error),
|
|
243
724
|
});
|
|
244
725
|
}
|
|
245
726
|
}
|
|
246
727
|
|
|
247
728
|
const failures = results.filter((item) => !item.ok);
|
|
729
|
+
const skipped = results.filter((item) =>
|
|
730
|
+
item.strategy?.startsWith("skipped:"),
|
|
731
|
+
);
|
|
732
|
+
const partial = results.some((item) => item.truncated === true);
|
|
248
733
|
return {
|
|
249
734
|
ok: true,
|
|
735
|
+
source: parsedMode,
|
|
250
736
|
accountId: resolvedAccount.accountId,
|
|
251
737
|
mentions: mentionIds.length,
|
|
252
738
|
threads: results.length,
|
|
253
|
-
succeeded: results.length - failures.length,
|
|
739
|
+
succeeded: results.length - failures.length - skipped.length,
|
|
740
|
+
skipped: skipped.length,
|
|
254
741
|
failed: failures.length,
|
|
255
742
|
mergedTweets,
|
|
256
743
|
uniqueTweets: uniqueTweetIds.size,
|
|
744
|
+
generalReadTweets: parsedMode === "xurl" ? generalReadTweets : 0,
|
|
745
|
+
partial,
|
|
257
746
|
options: {
|
|
747
|
+
mode: parsedMode,
|
|
258
748
|
limit: parsedLimit,
|
|
259
749
|
delayMs: parsedDelayMs,
|
|
260
750
|
timeoutMs: parsedTimeoutMs,
|
|
261
751
|
all,
|
|
262
752
|
maxPages: parsedMaxPages ?? null,
|
|
753
|
+
maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
|
|
263
754
|
},
|
|
264
755
|
results,
|
|
265
756
|
failures,
|
|
757
|
+
warnings,
|
|
266
758
|
};
|
|
267
759
|
}
|