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
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
import { getNativeDb } from "./db";
|
|
3
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
4
|
+
import type {
|
|
5
|
+
TweetEntities,
|
|
6
|
+
TweetMediaItem,
|
|
7
|
+
XurlMedia,
|
|
8
|
+
XurlMentionData,
|
|
9
|
+
XurlMentionUser,
|
|
10
|
+
XurlTweetData,
|
|
11
|
+
XurlTweetIncludes,
|
|
12
|
+
XurlUserTweet,
|
|
13
|
+
XurlUserTweetsResponse,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
16
|
+
import {
|
|
17
|
+
buildExternalProfileId,
|
|
18
|
+
ensureStubProfileForXUser,
|
|
19
|
+
upsertProfileFromXUser,
|
|
20
|
+
} from "./x-profile";
|
|
21
|
+
import {
|
|
22
|
+
getTransportStatus,
|
|
23
|
+
listUserTweets,
|
|
24
|
+
lookupAuthenticatedUser,
|
|
25
|
+
} from "./xurl";
|
|
26
|
+
|
|
27
|
+
export type AuthoredSyncMode = "xurl";
|
|
28
|
+
|
|
29
|
+
export class AuthoredSyncError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
public readonly exitCode: number,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "AuthoredSyncError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// sync_cache JSON shapes:
|
|
40
|
+
// { state: "committed", sinceId }
|
|
41
|
+
// { state: "pending-forward", sinceId, token, pendingNewestId }
|
|
42
|
+
// { state: "pending-until", sinceId, token, untilId, requestedSinceId }
|
|
43
|
+
type AuthoredCursorState =
|
|
44
|
+
| { state: "committed"; sinceId: string | null }
|
|
45
|
+
| {
|
|
46
|
+
state: "pending-forward";
|
|
47
|
+
sinceId: string | null;
|
|
48
|
+
token: string;
|
|
49
|
+
pendingNewestId: string | null;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
state: "pending-until";
|
|
53
|
+
sinceId: string | null;
|
|
54
|
+
token: string;
|
|
55
|
+
untilId: string;
|
|
56
|
+
requestedSinceId?: string | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
interface AuthoredPayload {
|
|
60
|
+
data: XurlMentionData[];
|
|
61
|
+
includes?: XurlTweetIncludes;
|
|
62
|
+
meta: {
|
|
63
|
+
result_count: number;
|
|
64
|
+
page_count: number;
|
|
65
|
+
next_token: string | null;
|
|
66
|
+
newest_id?: string;
|
|
67
|
+
oldest_id?: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MIN_XURL_LIMIT = 5;
|
|
72
|
+
const MAX_XURL_LIMIT = 100;
|
|
73
|
+
const DEFAULT_LIMIT = 100;
|
|
74
|
+
const AUTHORED_CURSOR_PREFIX = "authored:xurl";
|
|
75
|
+
const AUTHORED_TWEET_FIELDS = [
|
|
76
|
+
"author_id",
|
|
77
|
+
"created_at",
|
|
78
|
+
"conversation_id",
|
|
79
|
+
"entities",
|
|
80
|
+
"attachments",
|
|
81
|
+
"public_metrics",
|
|
82
|
+
"referenced_tweets",
|
|
83
|
+
];
|
|
84
|
+
const AUTHORED_EXPANSIONS = [
|
|
85
|
+
"author_id",
|
|
86
|
+
"referenced_tweets.id",
|
|
87
|
+
"referenced_tweets.id.author_id",
|
|
88
|
+
"attachments.media_keys",
|
|
89
|
+
];
|
|
90
|
+
const AUTHORED_USER_FIELDS = [
|
|
91
|
+
"description",
|
|
92
|
+
"entities",
|
|
93
|
+
"location",
|
|
94
|
+
"public_metrics",
|
|
95
|
+
"profile_image_url",
|
|
96
|
+
"url",
|
|
97
|
+
"created_at",
|
|
98
|
+
"verified",
|
|
99
|
+
"verified_type",
|
|
100
|
+
];
|
|
101
|
+
const AUTHORED_MEDIA_FIELDS = [
|
|
102
|
+
"media_key",
|
|
103
|
+
"type",
|
|
104
|
+
"url",
|
|
105
|
+
"preview_image_url",
|
|
106
|
+
"width",
|
|
107
|
+
"height",
|
|
108
|
+
"alt_text",
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
function assertXurlLimit(limit: number) {
|
|
112
|
+
if (
|
|
113
|
+
!Number.isFinite(limit) ||
|
|
114
|
+
limit < MIN_XURL_LIMIT ||
|
|
115
|
+
limit > MAX_XURL_LIMIT
|
|
116
|
+
) {
|
|
117
|
+
throw new Error("xurl mode requires --limit between 5 and 100");
|
|
118
|
+
}
|
|
119
|
+
return Math.floor(limit);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseMaxPages(value?: number) {
|
|
123
|
+
if (value === undefined) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
127
|
+
throw new Error("--max-pages must be at least 1");
|
|
128
|
+
}
|
|
129
|
+
return Math.floor(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cursorKey(accountId: string) {
|
|
133
|
+
return `${AUTHORED_CURSOR_PREFIX}:${accountId}:cursor`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeCursor(value: unknown): AuthoredCursorState {
|
|
137
|
+
if (!value || typeof value !== "object") {
|
|
138
|
+
return { state: "committed", sinceId: null };
|
|
139
|
+
}
|
|
140
|
+
const record = value as Record<string, unknown>;
|
|
141
|
+
const sinceId = typeof record.sinceId === "string" ? record.sinceId : null;
|
|
142
|
+
if (record.state === "pending-forward" && typeof record.token === "string") {
|
|
143
|
+
return {
|
|
144
|
+
state: "pending-forward",
|
|
145
|
+
sinceId,
|
|
146
|
+
token: record.token,
|
|
147
|
+
pendingNewestId:
|
|
148
|
+
typeof record.pendingNewestId === "string"
|
|
149
|
+
? record.pendingNewestId
|
|
150
|
+
: null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
record.state === "pending-until" &&
|
|
155
|
+
typeof record.token === "string" &&
|
|
156
|
+
typeof record.untilId === "string"
|
|
157
|
+
) {
|
|
158
|
+
const requestedSinceId =
|
|
159
|
+
"requestedSinceId" in record
|
|
160
|
+
? typeof record.requestedSinceId === "string"
|
|
161
|
+
? record.requestedSinceId
|
|
162
|
+
: null
|
|
163
|
+
: undefined;
|
|
164
|
+
return {
|
|
165
|
+
state: "pending-until",
|
|
166
|
+
sinceId,
|
|
167
|
+
token: record.token,
|
|
168
|
+
untilId: record.untilId,
|
|
169
|
+
...(requestedSinceId !== undefined ? { requestedSinceId } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const legacyToken =
|
|
173
|
+
typeof record.paginationToken === "string" ? record.paginationToken : null;
|
|
174
|
+
if (legacyToken) {
|
|
175
|
+
return {
|
|
176
|
+
state: "pending-forward",
|
|
177
|
+
sinceId,
|
|
178
|
+
token: legacyToken,
|
|
179
|
+
pendingNewestId:
|
|
180
|
+
typeof record.pendingNewestId === "string"
|
|
181
|
+
? record.pendingNewestId
|
|
182
|
+
: null,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { state: "committed", sinceId };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readAuthoredCursor(db: Database, accountId: string) {
|
|
189
|
+
return normalizeCursor(readSyncCache(cursorKey(accountId), db)?.value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function writeAuthoredCursor(
|
|
193
|
+
db: Database,
|
|
194
|
+
accountId: string,
|
|
195
|
+
state: AuthoredCursorState,
|
|
196
|
+
) {
|
|
197
|
+
writeSyncCache(cursorKey(accountId), state, db);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeCommittedCursor(
|
|
201
|
+
db: Database,
|
|
202
|
+
accountId: string,
|
|
203
|
+
sinceId: string | null,
|
|
204
|
+
) {
|
|
205
|
+
writeAuthoredCursor(db, accountId, { state: "committed", sinceId });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function writePendingForwardCursor(
|
|
209
|
+
db: Database,
|
|
210
|
+
accountId: string,
|
|
211
|
+
{
|
|
212
|
+
sinceId,
|
|
213
|
+
token,
|
|
214
|
+
pendingNewestId,
|
|
215
|
+
}: {
|
|
216
|
+
sinceId: string | null;
|
|
217
|
+
token: string;
|
|
218
|
+
pendingNewestId: string | null;
|
|
219
|
+
},
|
|
220
|
+
) {
|
|
221
|
+
writeAuthoredCursor(db, accountId, {
|
|
222
|
+
state: "pending-forward",
|
|
223
|
+
sinceId,
|
|
224
|
+
token,
|
|
225
|
+
pendingNewestId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writePendingUntilCursor(
|
|
230
|
+
db: Database,
|
|
231
|
+
accountId: string,
|
|
232
|
+
{
|
|
233
|
+
sinceId,
|
|
234
|
+
token,
|
|
235
|
+
untilId,
|
|
236
|
+
requestedSinceId,
|
|
237
|
+
}: {
|
|
238
|
+
sinceId: string | null;
|
|
239
|
+
token: string;
|
|
240
|
+
untilId: string;
|
|
241
|
+
requestedSinceId: string | null;
|
|
242
|
+
},
|
|
243
|
+
) {
|
|
244
|
+
writeAuthoredCursor(db, accountId, {
|
|
245
|
+
state: "pending-until",
|
|
246
|
+
sinceId,
|
|
247
|
+
token,
|
|
248
|
+
untilId,
|
|
249
|
+
requestedSinceId,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Archive seeds stay archive-only because backups can contain live edges without sync_cache.
|
|
254
|
+
function findArchiveAuthoredSinceSeed(db: Database, accountId: string) {
|
|
255
|
+
const row = db
|
|
256
|
+
.prepare(
|
|
257
|
+
`
|
|
258
|
+
select t.id
|
|
259
|
+
from tweets t
|
|
260
|
+
join accounts a on a.id = ?
|
|
261
|
+
where t.id glob '[0-9]*'
|
|
262
|
+
and t.id not glob '*[^0-9]*'
|
|
263
|
+
and (
|
|
264
|
+
exists (
|
|
265
|
+
select 1
|
|
266
|
+
from tweet_account_edges e
|
|
267
|
+
where e.account_id = ?
|
|
268
|
+
and e.tweet_id = t.id
|
|
269
|
+
and e.kind = 'authored'
|
|
270
|
+
and e.source = 'archive'
|
|
271
|
+
)
|
|
272
|
+
or (
|
|
273
|
+
t.kind = 'home'
|
|
274
|
+
and exists (
|
|
275
|
+
select 1
|
|
276
|
+
from tweet_account_edges e2
|
|
277
|
+
where e2.account_id = ?
|
|
278
|
+
and e2.tweet_id = t.id
|
|
279
|
+
and e2.source = 'archive'
|
|
280
|
+
and e2.kind = 'home'
|
|
281
|
+
)
|
|
282
|
+
and t.author_profile_id in ('profile_me', 'profile_user_' || a.external_user_id)
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
order by length(t.id) desc, t.id desc
|
|
286
|
+
limit 1
|
|
287
|
+
`,
|
|
288
|
+
)
|
|
289
|
+
.get(accountId, accountId, accountId) as { id: string } | undefined;
|
|
290
|
+
return row?.id ?? null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function compareTweetIds(
|
|
294
|
+
left: string | null | undefined,
|
|
295
|
+
right: string | null | undefined,
|
|
296
|
+
) {
|
|
297
|
+
if (!left && !right) {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
if (!left) {
|
|
301
|
+
return -1;
|
|
302
|
+
}
|
|
303
|
+
if (!right) {
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const leftBigInt = BigInt(left);
|
|
308
|
+
const rightBigInt = BigInt(right);
|
|
309
|
+
return leftBigInt === rightBigInt ? 0 : leftBigInt > rightBigInt ? 1 : -1;
|
|
310
|
+
} catch {
|
|
311
|
+
if (left.length !== right.length) {
|
|
312
|
+
return left.length > right.length ? 1 : -1;
|
|
313
|
+
}
|
|
314
|
+
return left.localeCompare(right);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function maxTweetId(...ids: Array<string | null | undefined>) {
|
|
319
|
+
return ids.reduce<string | null>((current, next) => {
|
|
320
|
+
if (!next) {
|
|
321
|
+
return current;
|
|
322
|
+
}
|
|
323
|
+
return compareTweetIds(next, current) > 0 ? next : current;
|
|
324
|
+
}, null);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function getNewestTweetId(tweets: XurlMentionData[]) {
|
|
328
|
+
return maxTweetId(...tweets.map((tweet) => tweet.id));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function getOldestTweetId(tweets: XurlMentionData[]) {
|
|
332
|
+
return tweets.reduce<string | null>((current, tweet) => {
|
|
333
|
+
if (!current) {
|
|
334
|
+
return tweet.id;
|
|
335
|
+
}
|
|
336
|
+
return compareTweetIds(tweet.id, current) < 0 ? tweet.id : current;
|
|
337
|
+
}, null);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
341
|
+
const row = accountId
|
|
342
|
+
? (db
|
|
343
|
+
.prepare(
|
|
344
|
+
"select id, handle, external_user_id from accounts where id = ?",
|
|
345
|
+
)
|
|
346
|
+
.get(accountId) as
|
|
347
|
+
| { id: string; handle: string; external_user_id: string | null }
|
|
348
|
+
| undefined)
|
|
349
|
+
: (db
|
|
350
|
+
.prepare(
|
|
351
|
+
`
|
|
352
|
+
select id, handle, external_user_id
|
|
353
|
+
from accounts
|
|
354
|
+
order by is_default desc, created_at asc
|
|
355
|
+
limit 1
|
|
356
|
+
`,
|
|
357
|
+
)
|
|
358
|
+
.get() as
|
|
359
|
+
| { id: string; handle: string; external_user_id: string | null }
|
|
360
|
+
| undefined);
|
|
361
|
+
|
|
362
|
+
if (!row) {
|
|
363
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
accountId: row.id,
|
|
368
|
+
username: row.handle.replace(/^@/, ""),
|
|
369
|
+
externalUserId:
|
|
370
|
+
typeof row.external_user_id === "string" &&
|
|
371
|
+
row.external_user_id.length > 0
|
|
372
|
+
? row.external_user_id
|
|
373
|
+
: undefined,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function normalizeUsername(value: string) {
|
|
378
|
+
return value.replace(/^@/, "").trim().toLowerCase();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function persistAccountExternalUserId(
|
|
382
|
+
db: Database,
|
|
383
|
+
accountId: string,
|
|
384
|
+
externalUserId: string,
|
|
385
|
+
) {
|
|
386
|
+
db.prepare(
|
|
387
|
+
`
|
|
388
|
+
update accounts
|
|
389
|
+
set external_user_id = ?
|
|
390
|
+
where id = ?
|
|
391
|
+
and (external_user_id is null or external_user_id = '')
|
|
392
|
+
`,
|
|
393
|
+
).run(externalUserId, accountId);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function userFromAuthenticatedPayload(
|
|
397
|
+
payload: Record<string, unknown> | null,
|
|
398
|
+
): XurlMentionUser | undefined {
|
|
399
|
+
if (!payload || typeof payload.id !== "string") {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
const username =
|
|
403
|
+
typeof payload.username === "string"
|
|
404
|
+
? payload.username.replace(/^@/, "")
|
|
405
|
+
: "";
|
|
406
|
+
if (!username) {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
id: payload.id,
|
|
411
|
+
username,
|
|
412
|
+
name: typeof payload.name === "string" ? payload.name : username,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function resolveAuthoredIdentity({
|
|
417
|
+
account,
|
|
418
|
+
db,
|
|
419
|
+
}: {
|
|
420
|
+
account?: string;
|
|
421
|
+
db: Database;
|
|
422
|
+
}) {
|
|
423
|
+
const status = await getTransportStatus();
|
|
424
|
+
if (status.availableTransport !== "xurl") {
|
|
425
|
+
throw new AuthoredSyncError(status.statusText, 4);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
429
|
+
if (resolvedAccount.externalUserId) {
|
|
430
|
+
return {
|
|
431
|
+
...resolvedAccount,
|
|
432
|
+
userId: resolvedAccount.externalUserId,
|
|
433
|
+
authenticatedUser: undefined,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const authenticated = await lookupAuthenticatedUser();
|
|
438
|
+
const authenticatedUser = userFromAuthenticatedPayload(authenticated);
|
|
439
|
+
if (!authenticatedUser?.id) {
|
|
440
|
+
throw new AuthoredSyncError(
|
|
441
|
+
"Could not resolve authenticated Twitter user id",
|
|
442
|
+
4,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (
|
|
447
|
+
normalizeUsername(authenticatedUser.username) !==
|
|
448
|
+
normalizeUsername(resolvedAccount.username)
|
|
449
|
+
) {
|
|
450
|
+
throw new AuthoredSyncError(
|
|
451
|
+
`xurl is authenticated as @${authenticatedUser.username}, but selected account ${resolvedAccount.accountId} is @${resolvedAccount.username}. Link the account external_user_id or switch xurl login before syncing authored tweets.`,
|
|
452
|
+
4,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
persistAccountExternalUserId(
|
|
457
|
+
db,
|
|
458
|
+
resolvedAccount.accountId,
|
|
459
|
+
authenticatedUser.id,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
...resolvedAccount,
|
|
464
|
+
userId: authenticatedUser.id,
|
|
465
|
+
authenticatedUser,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function toFallbackUser({
|
|
470
|
+
userId,
|
|
471
|
+
username,
|
|
472
|
+
authenticatedUser,
|
|
473
|
+
}: {
|
|
474
|
+
userId: string;
|
|
475
|
+
username: string;
|
|
476
|
+
authenticatedUser?: XurlMentionUser;
|
|
477
|
+
}): XurlMentionUser {
|
|
478
|
+
if (authenticatedUser?.id === userId) {
|
|
479
|
+
return authenticatedUser;
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
id: userId,
|
|
483
|
+
username: username || `user_${userId}`,
|
|
484
|
+
name: username || `user_${userId}`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
|
|
489
|
+
return {
|
|
490
|
+
...tweet,
|
|
491
|
+
author_id: tweet.author_id ?? fallbackAuthorId,
|
|
492
|
+
} satisfies XurlMentionData;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
496
|
+
const raw = tweet.entities;
|
|
497
|
+
if (!raw || typeof raw !== "object") {
|
|
498
|
+
return {};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const entities = raw as Record<string, unknown>;
|
|
502
|
+
const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
|
|
503
|
+
const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
504
|
+
const rawHashtags = Array.isArray(entities.hashtags) ? entities.hashtags : [];
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
...(rawMentions.length
|
|
508
|
+
? {
|
|
509
|
+
mentions: rawMentions.map((mention) => {
|
|
510
|
+
const value =
|
|
511
|
+
mention && typeof mention === "object"
|
|
512
|
+
? (mention as Record<string, unknown>)
|
|
513
|
+
: {};
|
|
514
|
+
return {
|
|
515
|
+
username: String(value.username ?? ""),
|
|
516
|
+
id: typeof value.id === "string" ? String(value.id) : undefined,
|
|
517
|
+
start: Number(value.start ?? 0),
|
|
518
|
+
end: Number(value.end ?? 0),
|
|
519
|
+
};
|
|
520
|
+
}),
|
|
521
|
+
}
|
|
522
|
+
: {}),
|
|
523
|
+
...(rawUrls.length
|
|
524
|
+
? {
|
|
525
|
+
urls: rawUrls.map((url) => {
|
|
526
|
+
const value =
|
|
527
|
+
url && typeof url === "object"
|
|
528
|
+
? (url as Record<string, unknown>)
|
|
529
|
+
: {};
|
|
530
|
+
return {
|
|
531
|
+
url: String(value.url ?? ""),
|
|
532
|
+
expandedUrl: String(value.expanded_url ?? value.url ?? ""),
|
|
533
|
+
displayUrl: String(
|
|
534
|
+
value.display_url ?? value.expanded_url ?? value.url ?? "",
|
|
535
|
+
),
|
|
536
|
+
start: Number(value.start ?? 0),
|
|
537
|
+
end: Number(value.end ?? 0),
|
|
538
|
+
};
|
|
539
|
+
}),
|
|
540
|
+
}
|
|
541
|
+
: {}),
|
|
542
|
+
...(rawHashtags.length
|
|
543
|
+
? {
|
|
544
|
+
hashtags: rawHashtags.map((hashtag) => {
|
|
545
|
+
const value =
|
|
546
|
+
hashtag && typeof hashtag === "object"
|
|
547
|
+
? (hashtag as Record<string, unknown>)
|
|
548
|
+
: {};
|
|
549
|
+
return {
|
|
550
|
+
tag: String(value.tag ?? ""),
|
|
551
|
+
start: Number(value.start ?? 0),
|
|
552
|
+
end: Number(value.end ?? 0),
|
|
553
|
+
};
|
|
554
|
+
}),
|
|
555
|
+
}
|
|
556
|
+
: {}),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function toMediaType(type: string): TweetMediaItem["type"] {
|
|
561
|
+
if (type === "photo" || type === "image") {
|
|
562
|
+
return "image";
|
|
563
|
+
}
|
|
564
|
+
if (type === "animated_gif" || type === "gif") {
|
|
565
|
+
return "gif";
|
|
566
|
+
}
|
|
567
|
+
return type === "video" ? "video" : "unknown";
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function toLocalMedia(
|
|
571
|
+
tweet: XurlMentionData,
|
|
572
|
+
mediaByKey: Map<string, XurlMedia>,
|
|
573
|
+
) {
|
|
574
|
+
const mediaKeys = tweet.attachments?.media_keys ?? [];
|
|
575
|
+
const seen = new Set<string>();
|
|
576
|
+
return mediaKeys
|
|
577
|
+
.map((mediaKey) => mediaByKey.get(mediaKey))
|
|
578
|
+
.filter((media): media is XurlMedia => Boolean(media))
|
|
579
|
+
.map((media) => {
|
|
580
|
+
const url = media.url ?? media.preview_image_url ?? "";
|
|
581
|
+
const thumbnailUrl = media.preview_image_url ?? media.url ?? "";
|
|
582
|
+
return {
|
|
583
|
+
url,
|
|
584
|
+
type: toMediaType(media.type),
|
|
585
|
+
...(typeof media.alt_text === "string"
|
|
586
|
+
? { altText: media.alt_text }
|
|
587
|
+
: {}),
|
|
588
|
+
...(typeof media.width === "number" ? { width: media.width } : {}),
|
|
589
|
+
...(typeof media.height === "number" ? { height: media.height } : {}),
|
|
590
|
+
...(thumbnailUrl ? { thumbnailUrl } : {}),
|
|
591
|
+
};
|
|
592
|
+
})
|
|
593
|
+
.filter((media) => {
|
|
594
|
+
const key = media.url || media.thumbnailUrl;
|
|
595
|
+
if (!key || seen.has(key)) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
seen.add(key);
|
|
599
|
+
return true;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function getMediaCount(tweet: XurlMentionData, media: TweetMediaItem[]) {
|
|
604
|
+
if (media.length > 0) {
|
|
605
|
+
return media.length;
|
|
606
|
+
}
|
|
607
|
+
if (tweet.attachments?.media_keys?.length) {
|
|
608
|
+
return tweet.attachments.media_keys.length;
|
|
609
|
+
}
|
|
610
|
+
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
611
|
+
return urls.filter(
|
|
612
|
+
(url) =>
|
|
613
|
+
url &&
|
|
614
|
+
typeof url === "object" &&
|
|
615
|
+
typeof (url as Record<string, unknown>).media_key === "string",
|
|
616
|
+
).length;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function getReferencedTweetId(tweet: XurlMentionData, type: string) {
|
|
620
|
+
return (
|
|
621
|
+
tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
626
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
627
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
628
|
+
tweetId,
|
|
629
|
+
text,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function findExistingProfileIdForUser(db: Database, user: XurlMentionUser) {
|
|
634
|
+
const username = String(user.username ?? "").replace(/^@/, "");
|
|
635
|
+
const row = db
|
|
636
|
+
.prepare(
|
|
637
|
+
`
|
|
638
|
+
select id
|
|
639
|
+
from profiles
|
|
640
|
+
where id = ? or handle = ?
|
|
641
|
+
limit 1
|
|
642
|
+
`,
|
|
643
|
+
)
|
|
644
|
+
.get(buildExternalProfileId(user.id), username) as
|
|
645
|
+
| { id: string }
|
|
646
|
+
| undefined;
|
|
647
|
+
return row?.id ?? null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function mergeAuthoredPayloadIntoLocalStore({
|
|
651
|
+
db,
|
|
652
|
+
accountId,
|
|
653
|
+
payload,
|
|
654
|
+
sourceUser,
|
|
655
|
+
}: {
|
|
656
|
+
db: Database;
|
|
657
|
+
accountId: string;
|
|
658
|
+
payload: AuthoredPayload;
|
|
659
|
+
sourceUser: XurlMentionUser;
|
|
660
|
+
}) {
|
|
661
|
+
const usersById = new Map(
|
|
662
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
663
|
+
);
|
|
664
|
+
const fallbackUserIds = new Set<string>();
|
|
665
|
+
if (!usersById.has(sourceUser.id)) {
|
|
666
|
+
usersById.set(sourceUser.id, sourceUser);
|
|
667
|
+
fallbackUserIds.add(sourceUser.id);
|
|
668
|
+
}
|
|
669
|
+
const mediaByKey = new Map(
|
|
670
|
+
(payload.includes?.media ?? []).map((media) => [media.media_key, media]),
|
|
671
|
+
);
|
|
672
|
+
const upsertTweet = db.prepare(
|
|
673
|
+
`
|
|
674
|
+
insert into tweets (
|
|
675
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
676
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
677
|
+
entities_json, media_json, quoted_tweet_id
|
|
678
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
679
|
+
on conflict(id) do update set
|
|
680
|
+
account_id = tweets.account_id,
|
|
681
|
+
author_profile_id = excluded.author_profile_id,
|
|
682
|
+
kind = case
|
|
683
|
+
when tweets.kind in ('home', 'mention', 'authored') then tweets.kind
|
|
684
|
+
when excluded.kind in ('home', 'mention', 'authored') then excluded.kind
|
|
685
|
+
else coalesce(nullif(tweets.kind, ''), excluded.kind)
|
|
686
|
+
end,
|
|
687
|
+
text = excluded.text,
|
|
688
|
+
created_at = excluded.created_at,
|
|
689
|
+
like_count = excluded.like_count,
|
|
690
|
+
media_count = excluded.media_count,
|
|
691
|
+
entities_json = excluded.entities_json,
|
|
692
|
+
media_json = excluded.media_json,
|
|
693
|
+
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
694
|
+
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
695
|
+
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
|
|
696
|
+
bookmarked = tweets.bookmarked,
|
|
697
|
+
liked = tweets.liked
|
|
698
|
+
`,
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const writeTweet = (tweet: XurlMentionData, kind: "authored" | "thread") => {
|
|
702
|
+
const author =
|
|
703
|
+
usersById.get(tweet.author_id) ??
|
|
704
|
+
({
|
|
705
|
+
id: tweet.author_id,
|
|
706
|
+
username: `user_${tweet.author_id}`,
|
|
707
|
+
name: `user_${tweet.author_id}`,
|
|
708
|
+
} as const);
|
|
709
|
+
const profileId =
|
|
710
|
+
usersById.has(tweet.author_id) && !fallbackUserIds.has(tweet.author_id)
|
|
711
|
+
? upsertProfileFromXUser(db, author).profile.id
|
|
712
|
+
: ((fallbackUserIds.has(tweet.author_id)
|
|
713
|
+
? findExistingProfileIdForUser(db, author)
|
|
714
|
+
: null) ??
|
|
715
|
+
ensureStubProfileForXUser(db, tweet.author_id).profile.id);
|
|
716
|
+
const replyToId = getReferencedTweetId(tweet, "replied_to");
|
|
717
|
+
const quotedTweetId = getReferencedTweetId(tweet, "quoted");
|
|
718
|
+
const media = toLocalMedia(tweet, mediaByKey);
|
|
719
|
+
upsertTweet.run(
|
|
720
|
+
tweet.id,
|
|
721
|
+
accountId,
|
|
722
|
+
profileId,
|
|
723
|
+
kind,
|
|
724
|
+
tweet.text,
|
|
725
|
+
tweet.created_at,
|
|
726
|
+
replyToId ? 1 : 0,
|
|
727
|
+
replyToId,
|
|
728
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
729
|
+
getMediaCount(tweet, media),
|
|
730
|
+
0,
|
|
731
|
+
0,
|
|
732
|
+
JSON.stringify(toLocalEntities(tweet)),
|
|
733
|
+
JSON.stringify(media),
|
|
734
|
+
quotedTweetId,
|
|
735
|
+
);
|
|
736
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
db.transaction(() => {
|
|
740
|
+
const seenAt = new Date().toISOString();
|
|
741
|
+
for (const includedTweet of payload.includes?.tweets ?? []) {
|
|
742
|
+
writeTweet(includedTweet, "thread");
|
|
743
|
+
}
|
|
744
|
+
for (const tweet of payload.data) {
|
|
745
|
+
writeTweet(tweet, "authored");
|
|
746
|
+
upsertTweetAccountEdge(db, {
|
|
747
|
+
accountId,
|
|
748
|
+
tweetId: tweet.id,
|
|
749
|
+
kind: "authored",
|
|
750
|
+
source: "xurl",
|
|
751
|
+
seenAt,
|
|
752
|
+
rawJson: JSON.stringify(tweet),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
})();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function appendUniqueById<T extends { id: string }>(
|
|
759
|
+
target: T[],
|
|
760
|
+
seenIds: Set<string>,
|
|
761
|
+
items: T[] | undefined,
|
|
762
|
+
) {
|
|
763
|
+
for (const item of items ?? []) {
|
|
764
|
+
if (seenIds.has(item.id)) {
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
seenIds.add(item.id);
|
|
768
|
+
target.push(item);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function appendUniqueMedia(
|
|
773
|
+
target: XurlMedia[],
|
|
774
|
+
seenIds: Set<string>,
|
|
775
|
+
items: XurlMedia[] | undefined,
|
|
776
|
+
) {
|
|
777
|
+
for (const item of items ?? []) {
|
|
778
|
+
if (seenIds.has(item.media_key)) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
seenIds.add(item.media_key);
|
|
782
|
+
target.push(item);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function mergePages({
|
|
787
|
+
pages,
|
|
788
|
+
userId,
|
|
789
|
+
nextToken,
|
|
790
|
+
}: {
|
|
791
|
+
pages: XurlUserTweetsResponse[];
|
|
792
|
+
userId: string;
|
|
793
|
+
nextToken: string | null;
|
|
794
|
+
}): AuthoredPayload {
|
|
795
|
+
const tweets: XurlMentionData[] = [];
|
|
796
|
+
const users: XurlMentionUser[] = [];
|
|
797
|
+
const includedTweets: XurlTweetData[] = [];
|
|
798
|
+
const media: XurlMedia[] = [];
|
|
799
|
+
const seenTweetIds = new Set<string>();
|
|
800
|
+
const seenUserIds = new Set<string>();
|
|
801
|
+
const seenIncludedTweetIds = new Set<string>();
|
|
802
|
+
const seenMediaKeys = new Set<string>();
|
|
803
|
+
|
|
804
|
+
for (const page of pages) {
|
|
805
|
+
for (const tweet of page.items) {
|
|
806
|
+
const normalized = toMentionData(tweet, userId);
|
|
807
|
+
if (seenTweetIds.has(normalized.id)) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
seenTweetIds.add(normalized.id);
|
|
811
|
+
tweets.push(normalized);
|
|
812
|
+
}
|
|
813
|
+
appendUniqueById(users, seenUserIds, page.includes?.users);
|
|
814
|
+
appendUniqueById(
|
|
815
|
+
includedTweets,
|
|
816
|
+
seenIncludedTweetIds,
|
|
817
|
+
page.includes?.tweets,
|
|
818
|
+
);
|
|
819
|
+
appendUniqueMedia(media, seenMediaKeys, page.includes?.media);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const newestId = getNewestTweetId(tweets);
|
|
823
|
+
const oldestId = getOldestTweetId(tweets);
|
|
824
|
+
const includes = {
|
|
825
|
+
...(users.length > 0 ? { users } : {}),
|
|
826
|
+
...(includedTweets.length > 0 ? { tweets: includedTweets } : {}),
|
|
827
|
+
...(media.length > 0 ? { media } : {}),
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
data: tweets,
|
|
832
|
+
...(Object.keys(includes).length > 0 ? { includes } : {}),
|
|
833
|
+
meta: {
|
|
834
|
+
result_count: tweets.length,
|
|
835
|
+
page_count: pages.length,
|
|
836
|
+
next_token: nextToken,
|
|
837
|
+
...(newestId ? { newest_id: newestId } : {}),
|
|
838
|
+
...(oldestId ? { oldest_id: oldestId } : {}),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function formatError(error: unknown) {
|
|
844
|
+
return error instanceof Error ? error.message : String(error);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function buildResult({
|
|
848
|
+
accountId,
|
|
849
|
+
userId,
|
|
850
|
+
effectiveSinceId,
|
|
851
|
+
nextSinceId,
|
|
852
|
+
nextToken,
|
|
853
|
+
pageCount,
|
|
854
|
+
payload,
|
|
855
|
+
partial,
|
|
856
|
+
error,
|
|
857
|
+
}: {
|
|
858
|
+
accountId: string;
|
|
859
|
+
userId: string;
|
|
860
|
+
effectiveSinceId: string | null;
|
|
861
|
+
nextSinceId: string | null;
|
|
862
|
+
nextToken: string | null;
|
|
863
|
+
pageCount: number;
|
|
864
|
+
payload: AuthoredPayload;
|
|
865
|
+
partial: boolean;
|
|
866
|
+
error?: string;
|
|
867
|
+
}) {
|
|
868
|
+
return {
|
|
869
|
+
ok: !partial,
|
|
870
|
+
kind: "authored" as const,
|
|
871
|
+
source: "xurl" as const,
|
|
872
|
+
accountId,
|
|
873
|
+
userId,
|
|
874
|
+
count: payload.data.length,
|
|
875
|
+
pages: pageCount,
|
|
876
|
+
sinceId: effectiveSinceId,
|
|
877
|
+
nextSinceId,
|
|
878
|
+
nextToken,
|
|
879
|
+
partial,
|
|
880
|
+
...(error ? { error } : {}),
|
|
881
|
+
cursor: {
|
|
882
|
+
sinceId: nextSinceId,
|
|
883
|
+
paginationToken: nextToken,
|
|
884
|
+
pending: Boolean(nextToken),
|
|
885
|
+
},
|
|
886
|
+
payload,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export async function syncAuthoredTweets({
|
|
891
|
+
account,
|
|
892
|
+
mode = "xurl",
|
|
893
|
+
limit = DEFAULT_LIMIT,
|
|
894
|
+
maxPages,
|
|
895
|
+
sinceId,
|
|
896
|
+
untilId,
|
|
897
|
+
}: {
|
|
898
|
+
account?: string;
|
|
899
|
+
mode?: AuthoredSyncMode;
|
|
900
|
+
limit?: number;
|
|
901
|
+
maxPages?: number;
|
|
902
|
+
sinceId?: string;
|
|
903
|
+
untilId?: string;
|
|
904
|
+
}) {
|
|
905
|
+
if (mode !== "xurl") {
|
|
906
|
+
throw new Error("authored sync only supports --mode xurl");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const pageLimit = assertXurlLimit(limit);
|
|
910
|
+
const parsedMaxPages = parseMaxPages(maxPages);
|
|
911
|
+
const db = getNativeDb();
|
|
912
|
+
const identity = await resolveAuthoredIdentity({ account, db });
|
|
913
|
+
const cursor = readAuthoredCursor(db, identity.accountId);
|
|
914
|
+
const usePersistedForward =
|
|
915
|
+
sinceId === undefined && !untilId && cursor.state === "pending-forward";
|
|
916
|
+
const usePersistedUntil =
|
|
917
|
+
sinceId === undefined &&
|
|
918
|
+
Boolean(untilId) &&
|
|
919
|
+
cursor.state === "pending-until" &&
|
|
920
|
+
cursor.untilId === untilId;
|
|
921
|
+
const shouldSeedFromArchive =
|
|
922
|
+
!usePersistedForward &&
|
|
923
|
+
!cursor.sinceId &&
|
|
924
|
+
sinceId === undefined &&
|
|
925
|
+
!untilId;
|
|
926
|
+
const archiveSinceSeed = shouldSeedFromArchive
|
|
927
|
+
? findArchiveAuthoredSinceSeed(db, identity.accountId)
|
|
928
|
+
: null;
|
|
929
|
+
if (shouldSeedFromArchive && !archiveSinceSeed) {
|
|
930
|
+
console.error(
|
|
931
|
+
"birdclaw sync authored: no archive baseline found; starting a full backwards scan",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
const persistedUntilSinceId: string | null = usePersistedUntil
|
|
935
|
+
? (("requestedSinceId" in cursor
|
|
936
|
+
? cursor.requestedSinceId
|
|
937
|
+
: cursor.sinceId) ?? null)
|
|
938
|
+
: null;
|
|
939
|
+
const effectiveSinceId: string | null =
|
|
940
|
+
sinceId ??
|
|
941
|
+
archiveSinceSeed ??
|
|
942
|
+
(untilId ? persistedUntilSinceId : cursor.sinceId) ??
|
|
943
|
+
null;
|
|
944
|
+
let nextToken = usePersistedForward
|
|
945
|
+
? cursor.token
|
|
946
|
+
: usePersistedUntil
|
|
947
|
+
? cursor.token
|
|
948
|
+
: undefined;
|
|
949
|
+
let newestSeenId = usePersistedForward
|
|
950
|
+
? maxTweetId(cursor.sinceId, cursor.pendingNewestId)
|
|
951
|
+
: cursor.sinceId;
|
|
952
|
+
const pages: XurlUserTweetsResponse[] = [];
|
|
953
|
+
const sourceUser = toFallbackUser({
|
|
954
|
+
userId: identity.userId,
|
|
955
|
+
username: identity.username,
|
|
956
|
+
authenticatedUser: identity.authenticatedUser,
|
|
957
|
+
});
|
|
958
|
+
let pageCount = 0;
|
|
959
|
+
|
|
960
|
+
while (parsedMaxPages === null || pageCount < parsedMaxPages) {
|
|
961
|
+
let page: XurlUserTweetsResponse;
|
|
962
|
+
try {
|
|
963
|
+
page = await listUserTweets(identity.userId, {
|
|
964
|
+
maxResults: pageLimit,
|
|
965
|
+
paginationToken: nextToken,
|
|
966
|
+
excludeRetweets: false,
|
|
967
|
+
sinceId: effectiveSinceId ?? undefined,
|
|
968
|
+
untilId,
|
|
969
|
+
tweetFields: AUTHORED_TWEET_FIELDS,
|
|
970
|
+
expansions: AUTHORED_EXPANSIONS,
|
|
971
|
+
userFields: AUTHORED_USER_FIELDS,
|
|
972
|
+
mediaFields: AUTHORED_MEDIA_FIELDS,
|
|
973
|
+
auth: "oauth2",
|
|
974
|
+
});
|
|
975
|
+
} catch (error) {
|
|
976
|
+
if (pages.length === 0) {
|
|
977
|
+
throw error;
|
|
978
|
+
}
|
|
979
|
+
const payload = mergePages({
|
|
980
|
+
pages,
|
|
981
|
+
userId: identity.userId,
|
|
982
|
+
nextToken: nextToken ?? null,
|
|
983
|
+
});
|
|
984
|
+
return buildResult({
|
|
985
|
+
accountId: identity.accountId,
|
|
986
|
+
userId: identity.userId,
|
|
987
|
+
effectiveSinceId,
|
|
988
|
+
nextSinceId: untilId ? cursor.sinceId : effectiveSinceId,
|
|
989
|
+
nextToken: nextToken ?? null,
|
|
990
|
+
pageCount,
|
|
991
|
+
payload,
|
|
992
|
+
partial: true,
|
|
993
|
+
error: formatError(error),
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const pagePayload = mergePages({
|
|
998
|
+
pages: [page],
|
|
999
|
+
userId: identity.userId,
|
|
1000
|
+
nextToken: page.nextToken,
|
|
1001
|
+
});
|
|
1002
|
+
mergeAuthoredPayloadIntoLocalStore({
|
|
1003
|
+
db,
|
|
1004
|
+
accountId: identity.accountId,
|
|
1005
|
+
payload: pagePayload,
|
|
1006
|
+
sourceUser,
|
|
1007
|
+
});
|
|
1008
|
+
pages.push(page);
|
|
1009
|
+
pageCount += 1;
|
|
1010
|
+
newestSeenId = maxTweetId(newestSeenId, pagePayload.meta.newest_id);
|
|
1011
|
+
nextToken = page.nextToken ?? undefined;
|
|
1012
|
+
|
|
1013
|
+
if (nextToken && untilId) {
|
|
1014
|
+
writePendingUntilCursor(db, identity.accountId, {
|
|
1015
|
+
sinceId: cursor.sinceId,
|
|
1016
|
+
token: nextToken,
|
|
1017
|
+
untilId,
|
|
1018
|
+
requestedSinceId: effectiveSinceId,
|
|
1019
|
+
});
|
|
1020
|
+
} else if (nextToken) {
|
|
1021
|
+
writePendingForwardCursor(db, identity.accountId, {
|
|
1022
|
+
sinceId: effectiveSinceId,
|
|
1023
|
+
token: nextToken,
|
|
1024
|
+
pendingNewestId: newestSeenId,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (!nextToken) {
|
|
1029
|
+
break;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const capped = Boolean(nextToken);
|
|
1034
|
+
const nextSinceId = untilId
|
|
1035
|
+
? cursor.sinceId
|
|
1036
|
+
: capped
|
|
1037
|
+
? effectiveSinceId
|
|
1038
|
+
: maxTweetId(newestSeenId, effectiveSinceId, cursor.sinceId);
|
|
1039
|
+
if (untilId && capped && nextToken) {
|
|
1040
|
+
writePendingUntilCursor(db, identity.accountId, {
|
|
1041
|
+
sinceId: cursor.sinceId,
|
|
1042
|
+
token: nextToken,
|
|
1043
|
+
untilId,
|
|
1044
|
+
requestedSinceId: effectiveSinceId,
|
|
1045
|
+
});
|
|
1046
|
+
} else if (untilId) {
|
|
1047
|
+
writeCommittedCursor(db, identity.accountId, cursor.sinceId);
|
|
1048
|
+
} else if (capped && nextToken) {
|
|
1049
|
+
writePendingForwardCursor(db, identity.accountId, {
|
|
1050
|
+
sinceId: nextSinceId,
|
|
1051
|
+
token: nextToken,
|
|
1052
|
+
pendingNewestId: newestSeenId,
|
|
1053
|
+
});
|
|
1054
|
+
} else {
|
|
1055
|
+
writeCommittedCursor(db, identity.accountId, nextSinceId);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const payload = mergePages({
|
|
1059
|
+
pages,
|
|
1060
|
+
userId: identity.userId,
|
|
1061
|
+
nextToken: nextToken ?? null,
|
|
1062
|
+
});
|
|
1063
|
+
return buildResult({
|
|
1064
|
+
accountId: identity.accountId,
|
|
1065
|
+
userId: identity.userId,
|
|
1066
|
+
effectiveSinceId,
|
|
1067
|
+
nextSinceId,
|
|
1068
|
+
nextToken: nextToken ?? null,
|
|
1069
|
+
pageCount,
|
|
1070
|
+
payload,
|
|
1071
|
+
partial: capped,
|
|
1072
|
+
...(capped ? { error: "max pages reached before sync completed" } : {}),
|
|
1073
|
+
});
|
|
1074
|
+
}
|