birdclaw 0.8.3 → 0.8.4
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 +17 -0
- package/README.md +10 -8
- package/bin/birdclaw.mjs +4 -66
- package/dist/cli/birdclaw.js +28165 -0
- package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
- package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
- package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
- package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
- package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
- package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
- package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
- package/dist/client/assets/chunk-C0phanGV.js +1 -0
- package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
- package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
- package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
- package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
- package/dist/client/assets/discuss-DTjaOugw.js +1 -0
- package/dist/client/assets/dms-CGd6Eogq.js +1 -0
- package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
- package/dist/client/assets/index-CEIG7S6s.js +31 -0
- package/dist/client/assets/index-bpFrQ5yG.css +1 -0
- package/dist/client/assets/likes-cLrcAQem.js +1 -0
- package/dist/client/assets/links-CM9QO1LC.js +1 -0
- package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
- package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
- package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
- package/dist/client/assets/network-map-B5a3whqj.js +2 -0
- package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
- package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
- package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
- package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
- package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
- package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
- package/dist/client/assets/react-nBylq1U5.js +1 -0
- package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
- package/dist/client/assets/routes-q7ZaW4En.js +1 -0
- package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
- package/dist/client/assets/search-Dq5kYsxA.js +1 -0
- package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
- package/dist/client/assets/styles-B8s3tt3r.css +2 -0
- package/dist/client/assets/today-CeG1O7Py.js +1 -0
- package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
- package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
- package/dist/client/assets/users-CHbKlqUr.js +1 -0
- package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
- package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
- package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
- package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
- package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
- package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
- package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
- package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
- package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
- package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
- package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
- package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
- package/dist/server/assets/blocks-DSgQghqP.js +17 -0
- package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
- package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
- package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
- package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
- package/dist/server/assets/discuss-QTo08NCN.js +338 -0
- package/dist/server/assets/dms-BuLj4bp8.js +368 -0
- package/dist/server/assets/dms-DAyQrVM2.js +240 -0
- package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
- package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
- package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
- package/dist/server/assets/likes-DBOAtsJU.js +14 -0
- package/dist/server/assets/links-DAhSjynu.js +240 -0
- package/dist/server/assets/links-DmH5lxHO.js +468 -0
- package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
- package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
- package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
- package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
- package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
- package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
- package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
- package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
- package/dist/server/assets/query-client-BdauHBgd.js +923 -0
- package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
- package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
- package/dist/server/assets/router-DqkwSPiM.js +17026 -0
- package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
- package/dist/server/assets/start-FaDOTmto.js +4 -0
- package/dist/server/assets/today-B5hVM57Z.js +17 -0
- package/dist/server/assets/today-fvGiZ7ud.js +303 -0
- package/dist/server/assets/ui-qbmcMSSX.js +137 -0
- package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
- package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
- package/dist/server/server.js +5520 -0
- package/package.json +26 -34
- package/playwright.config.ts +0 -35
- package/scripts/browser-perf.mjs +0 -427
- package/scripts/build-docs-site.mjs +0 -967
- package/scripts/docs-site-assets.mjs +0 -311
- package/scripts/run-vitest.mjs +0 -21
- package/scripts/sanitize-node-options.mjs +0 -23
- package/scripts/start-test-server.mjs +0 -42
- package/src/cli/command-context.ts +0 -17
- package/src/cli/register-analysis.ts +0 -500
- package/src/cli/register-compose.ts +0 -40
- package/src/cli/register-graph.ts +0 -132
- package/src/cli/register-inbox.ts +0 -41
- package/src/cli/register-storage.ts +0 -106
- package/src/cli-moderation.ts +0 -210
- package/src/cli.ts +0 -1616
- package/src/components/AccountSwitcher.tsx +0 -178
- package/src/components/AppNav.tsx +0 -118
- package/src/components/AvatarChip.tsx +0 -58
- package/src/components/AvatarPreload.ts +0 -149
- package/src/components/BrandMark.tsx +0 -67
- package/src/components/ConversationThread.tsx +0 -131
- package/src/components/DmWorkspace.tsx +0 -286
- package/src/components/EmbeddedTweetCard.tsx +0 -55
- package/src/components/FeedState.tsx +0 -147
- package/src/components/FloatingPreview.tsx +0 -382
- package/src/components/InboxCard.tsx +0 -152
- package/src/components/LinkPreviewCard.tsx +0 -292
- package/src/components/MarkdownCitations.tsx +0 -680
- package/src/components/MarkdownViewer.tsx +0 -111
- package/src/components/ProfileAnalysisClient.ts +0 -191
- package/src/components/ProfileAnalysisStream.tsx +0 -259
- package/src/components/ProfilePreview.tsx +0 -131
- package/src/components/SavedTimelineView.tsx +0 -155
- package/src/components/SmartTimestamp.tsx +0 -72
- package/src/components/SyncNowButton.tsx +0 -140
- package/src/components/ThemeSlider.tsx +0 -80
- package/src/components/TimelineCard.tsx +0 -533
- package/src/components/TimelineRouteFrame.tsx +0 -186
- package/src/components/TweetArticleCard.tsx +0 -66
- package/src/components/TweetMediaGrid.tsx +0 -185
- package/src/components/TweetRichText.tsx +0 -159
- package/src/components/account-selection.ts +0 -64
- package/src/components/links-controller.ts +0 -162
- package/src/components/links-model.ts +0 -198
- package/src/components/network-map-controller.ts +0 -84
- package/src/components/network-map-model.ts +0 -255
- package/src/components/useTimelineRouteData.ts +0 -193
- package/src/lib/account-sync-job.ts +0 -666
- package/src/lib/actions-transport.ts +0 -300
- package/src/lib/analysis-runtime.ts +0 -238
- package/src/lib/api-client.ts +0 -220
- package/src/lib/api-contracts.ts +0 -328
- package/src/lib/archive-finder.ts +0 -185
- package/src/lib/archive-import-plan.ts +0 -102
- package/src/lib/archive-import.ts +0 -2695
- package/src/lib/authored-live.ts +0 -1027
- package/src/lib/avatar-cache.ts +0 -349
- package/src/lib/backup.ts +0 -2652
- package/src/lib/bird-actions.ts +0 -294
- package/src/lib/bird-command.ts +0 -130
- package/src/lib/bird.ts +0 -1140
- package/src/lib/blocklist.ts +0 -136
- package/src/lib/blocks-write.ts +0 -171
- package/src/lib/blocks.ts +0 -387
- package/src/lib/bookmark-sync-job.ts +0 -536
- package/src/lib/config.ts +0 -224
- package/src/lib/conversation-surface.ts +0 -205
- package/src/lib/data-sources.ts +0 -219
- package/src/lib/database-metrics.ts +0 -88
- package/src/lib/database-migrations.ts +0 -34
- package/src/lib/database-schema.ts +0 -312
- package/src/lib/database-writer.ts +0 -69
- package/src/lib/db.ts +0 -889
- package/src/lib/dm-read-model.ts +0 -533
- package/src/lib/dms-live.ts +0 -870
- package/src/lib/effect-runtime.ts +0 -45
- package/src/lib/follow-graph.ts +0 -1087
- package/src/lib/geocoding.ts +0 -296
- package/src/lib/http-effect.ts +0 -222
- package/src/lib/identity-search-index.ts +0 -369
- package/src/lib/import-repository.ts +0 -138
- package/src/lib/inbox.ts +0 -242
- package/src/lib/link-index.ts +0 -641
- package/src/lib/link-insights.ts +0 -860
- package/src/lib/link-preview-metadata.ts +0 -754
- package/src/lib/live-sync-engine.ts +0 -209
- package/src/lib/live-transport-gateway.ts +0 -128
- package/src/lib/location.ts +0 -137
- package/src/lib/media-fetch.ts +0 -860
- package/src/lib/media-includes.ts +0 -165
- package/src/lib/mention-threads-live.ts +0 -830
- package/src/lib/mentions-export.ts +0 -147
- package/src/lib/mentions-live.ts +0 -1114
- package/src/lib/moderation-target.ts +0 -227
- package/src/lib/moderation-write.ts +0 -131
- package/src/lib/mutes-write.ts +0 -171
- package/src/lib/mutes.ts +0 -84
- package/src/lib/ndjson-stream.ts +0 -106
- package/src/lib/network-map.ts +0 -382
- package/src/lib/openai-response-runtime.ts +0 -251
- package/src/lib/openai.ts +0 -117
- package/src/lib/paginated-sync.ts +0 -93
- package/src/lib/period-digest.ts +0 -1364
- package/src/lib/present.ts +0 -118
- package/src/lib/profile-affiliation-hydration.ts +0 -228
- package/src/lib/profile-affiliations.ts +0 -262
- package/src/lib/profile-analysis.ts +0 -1198
- package/src/lib/profile-bio-entities.ts +0 -318
- package/src/lib/profile-history.ts +0 -164
- package/src/lib/profile-hydration.ts +0 -180
- package/src/lib/profile-replies.ts +0 -77
- package/src/lib/profile-resolver.ts +0 -650
- package/src/lib/queries.ts +0 -6
- package/src/lib/query-actions.ts +0 -437
- package/src/lib/query-client.tsx +0 -47
- package/src/lib/query-read-model-shared.ts +0 -52
- package/src/lib/query-read-models.ts +0 -5
- package/src/lib/query-resource.ts +0 -41
- package/src/lib/query-status.ts +0 -164
- package/src/lib/research.ts +0 -652
- package/src/lib/runtime-services.ts +0 -20
- package/src/lib/search-discussion.ts +0 -812
- package/src/lib/seed.ts +0 -613
- package/src/lib/server-runtime-services.ts +0 -30
- package/src/lib/sqlite.ts +0 -183
- package/src/lib/streaming-ingestion.ts +0 -240
- package/src/lib/sync-cache.ts +0 -70
- package/src/lib/sync-plan.ts +0 -175
- package/src/lib/theme-transition.ts +0 -113
- package/src/lib/theme.tsx +0 -165
- package/src/lib/timeline-collections-live.ts +0 -407
- package/src/lib/timeline-live.ts +0 -302
- package/src/lib/timeline-read-model.ts +0 -1191
- package/src/lib/tweet-account-edges.ts +0 -48
- package/src/lib/tweet-lookup.ts +0 -46
- package/src/lib/tweet-render.ts +0 -430
- package/src/lib/tweet-repository.ts +0 -156
- package/src/lib/tweet-search-live.ts +0 -462
- package/src/lib/types.ts +0 -723
- package/src/lib/ui.ts +0 -529
- package/src/lib/url-expansion-store.ts +0 -110
- package/src/lib/url-expansion.ts +0 -343
- package/src/lib/url-safety.ts +0 -220
- package/src/lib/web-sync.ts +0 -533
- package/src/lib/whois.ts +0 -1080
- package/src/lib/x-profile.ts +0 -365
- package/src/lib/x-web.ts +0 -199
- package/src/lib/xurl-rate-limits.ts +0 -267
- package/src/lib/xurl.ts +0 -1677
- package/src/routeTree.gen.ts +0 -723
- package/src/router.tsx +0 -20
- package/src/routes/__root.tsx +0 -82
- package/src/routes/api/action.tsx +0 -147
- package/src/routes/api/avatar.tsx +0 -50
- package/src/routes/api/blocks.tsx +0 -35
- package/src/routes/api/conversation.tsx +0 -45
- package/src/routes/api/data-sources.tsx +0 -24
- package/src/routes/api/inbox.tsx +0 -43
- package/src/routes/api/link-insights.tsx +0 -85
- package/src/routes/api/link-preview.tsx +0 -47
- package/src/routes/api/network-map.tsx +0 -55
- package/src/routes/api/period-digest.tsx +0 -101
- package/src/routes/api/profile-analysis.tsx +0 -97
- package/src/routes/api/profile-hydrate.tsx +0 -54
- package/src/routes/api/query.tsx +0 -121
- package/src/routes/api/search-discussion.tsx +0 -113
- package/src/routes/api/status.tsx +0 -30
- package/src/routes/api/sync.tsx +0 -108
- package/src/routes/api/xurl-rate-limits.tsx +0 -24
- package/src/routes/blocks.tsx +0 -343
- package/src/routes/bookmarks.tsx +0 -18
- package/src/routes/data-sources.tsx +0 -249
- package/src/routes/discuss.tsx +0 -419
- package/src/routes/dms.tsx +0 -415
- package/src/routes/inbox.tsx +0 -232
- package/src/routes/index.tsx +0 -32
- package/src/routes/likes.tsx +0 -18
- package/src/routes/links.tsx +0 -631
- package/src/routes/mentions.tsx +0 -32
- package/src/routes/network-map.tsx +0 -732
- package/src/routes/profile-analyze.tsx +0 -112
- package/src/routes/profiles.$handle.tsx +0 -228
- package/src/routes/rate-limits.tsx +0 -305
- package/src/routes/today.tsx +0 -511
- package/src/styles.css +0 -257
- package/tsconfig.json +0 -29
- package/vite.config.ts +0 -29
- /package/{public → dist/client}/birdclaw-mark.png +0 -0
- /package/{public → dist/client}/favicon.ico +0 -0
- /package/{public → dist/client}/logo192.png +0 -0
- /package/{public → dist/client}/logo512.png +0 -0
- /package/{public → dist/client}/manifest.json +0 -0
- /package/{public → dist/client}/robots.txt +0 -0
package/src/lib/authored-live.ts
DELETED
|
@@ -1,1027 +0,0 @@
|
|
|
1
|
-
import type { Database } from "./sqlite";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { databaseWriteEffect } from "./database-writer";
|
|
4
|
-
import { getNativeDb } from "./db";
|
|
5
|
-
import { runEffectPromise } from "./effect-runtime";
|
|
6
|
-
import { liveTransportGateway } from "./live-transport-gateway";
|
|
7
|
-
import { resolveLiveSyncAccount } from "./live-sync-engine";
|
|
8
|
-
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
9
|
-
import { runSyncPlanEffect } from "./sync-plan";
|
|
10
|
-
import { tweetEntitiesFromXurl } from "./tweet-render";
|
|
11
|
-
import type {
|
|
12
|
-
TweetEntities,
|
|
13
|
-
TweetMediaItem,
|
|
14
|
-
XurlMedia,
|
|
15
|
-
XurlMentionData,
|
|
16
|
-
XurlMentionUser,
|
|
17
|
-
XurlTweetData,
|
|
18
|
-
XurlTweetIncludes,
|
|
19
|
-
XurlUserTweet,
|
|
20
|
-
XurlUserTweetsResponse,
|
|
21
|
-
} from "./types";
|
|
22
|
-
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
23
|
-
import {
|
|
24
|
-
buildExternalProfileId,
|
|
25
|
-
ensureStubProfileForXUser,
|
|
26
|
-
upsertProfileFromXUser,
|
|
27
|
-
} from "./x-profile";
|
|
28
|
-
|
|
29
|
-
export type AuthoredSyncMode = "xurl";
|
|
30
|
-
|
|
31
|
-
export interface SyncAuthoredTweetsOptions {
|
|
32
|
-
account?: string;
|
|
33
|
-
mode?: AuthoredSyncMode;
|
|
34
|
-
limit?: number;
|
|
35
|
-
maxPages?: number;
|
|
36
|
-
sinceId?: string;
|
|
37
|
-
untilId?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class AuthoredSyncError extends Error {
|
|
41
|
-
constructor(
|
|
42
|
-
message: string,
|
|
43
|
-
public readonly exitCode: number,
|
|
44
|
-
) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.name = "AuthoredSyncError";
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// sync_cache JSON shapes:
|
|
51
|
-
// { state: "committed", sinceId }
|
|
52
|
-
// { state: "pending-forward", sinceId, token, pendingNewestId }
|
|
53
|
-
// { state: "pending-until", sinceId, token, untilId, requestedSinceId }
|
|
54
|
-
type AuthoredCursorState =
|
|
55
|
-
| { state: "committed"; sinceId: string | null }
|
|
56
|
-
| {
|
|
57
|
-
state: "pending-forward";
|
|
58
|
-
sinceId: string | null;
|
|
59
|
-
token: string;
|
|
60
|
-
pendingNewestId: string | null;
|
|
61
|
-
}
|
|
62
|
-
| {
|
|
63
|
-
state: "pending-until";
|
|
64
|
-
sinceId: string | null;
|
|
65
|
-
token: string;
|
|
66
|
-
untilId: string;
|
|
67
|
-
requestedSinceId?: string | null;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
interface AuthoredPayload {
|
|
71
|
-
data: XurlMentionData[];
|
|
72
|
-
includes?: XurlTweetIncludes;
|
|
73
|
-
meta: {
|
|
74
|
-
result_count: number;
|
|
75
|
-
page_count: number;
|
|
76
|
-
next_token: string | null;
|
|
77
|
-
newest_id?: string;
|
|
78
|
-
oldest_id?: string;
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const MIN_XURL_LIMIT = 5;
|
|
83
|
-
const MAX_XURL_LIMIT = 100;
|
|
84
|
-
const DEFAULT_LIMIT = 100;
|
|
85
|
-
const AUTHORED_CURSOR_PREFIX = "authored:xurl";
|
|
86
|
-
const AUTHORED_TWEET_FIELDS = [
|
|
87
|
-
"author_id",
|
|
88
|
-
"created_at",
|
|
89
|
-
"conversation_id",
|
|
90
|
-
"entities",
|
|
91
|
-
"attachments",
|
|
92
|
-
"public_metrics",
|
|
93
|
-
"referenced_tweets",
|
|
94
|
-
];
|
|
95
|
-
const AUTHORED_EXPANSIONS = [
|
|
96
|
-
"author_id",
|
|
97
|
-
"referenced_tweets.id",
|
|
98
|
-
"referenced_tweets.id.author_id",
|
|
99
|
-
"attachments.media_keys",
|
|
100
|
-
];
|
|
101
|
-
const AUTHORED_USER_FIELDS = [
|
|
102
|
-
"description",
|
|
103
|
-
"entities",
|
|
104
|
-
"location",
|
|
105
|
-
"public_metrics",
|
|
106
|
-
"profile_image_url",
|
|
107
|
-
"url",
|
|
108
|
-
"created_at",
|
|
109
|
-
"verified",
|
|
110
|
-
"verified_type",
|
|
111
|
-
];
|
|
112
|
-
const AUTHORED_MEDIA_FIELDS = [
|
|
113
|
-
"media_key",
|
|
114
|
-
"type",
|
|
115
|
-
"url",
|
|
116
|
-
"preview_image_url",
|
|
117
|
-
"width",
|
|
118
|
-
"height",
|
|
119
|
-
"alt_text",
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
function toError(error: unknown) {
|
|
123
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function trySync<T>(try_: () => T) {
|
|
127
|
-
return Effect.try({
|
|
128
|
-
try: try_,
|
|
129
|
-
catch: toError,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function assertXurlLimit(limit: number) {
|
|
134
|
-
if (
|
|
135
|
-
!Number.isFinite(limit) ||
|
|
136
|
-
limit < MIN_XURL_LIMIT ||
|
|
137
|
-
limit > MAX_XURL_LIMIT
|
|
138
|
-
) {
|
|
139
|
-
throw new Error("xurl mode requires --limit between 5 and 100");
|
|
140
|
-
}
|
|
141
|
-
return Math.floor(limit);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function parseMaxPages(value?: number) {
|
|
145
|
-
if (value === undefined) {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
if (!Number.isFinite(value) || value < 1) {
|
|
149
|
-
throw new Error("--max-pages must be at least 1");
|
|
150
|
-
}
|
|
151
|
-
return Math.floor(value);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function cursorKey(accountId: string) {
|
|
155
|
-
return `${AUTHORED_CURSOR_PREFIX}:${accountId}:cursor`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function normalizeCursor(value: unknown): AuthoredCursorState {
|
|
159
|
-
if (!value || typeof value !== "object") {
|
|
160
|
-
return { state: "committed", sinceId: null };
|
|
161
|
-
}
|
|
162
|
-
const record = value as Record<string, unknown>;
|
|
163
|
-
const sinceId = typeof record.sinceId === "string" ? record.sinceId : null;
|
|
164
|
-
if (record.state === "pending-forward" && typeof record.token === "string") {
|
|
165
|
-
return {
|
|
166
|
-
state: "pending-forward",
|
|
167
|
-
sinceId,
|
|
168
|
-
token: record.token,
|
|
169
|
-
pendingNewestId:
|
|
170
|
-
typeof record.pendingNewestId === "string"
|
|
171
|
-
? record.pendingNewestId
|
|
172
|
-
: null,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
if (
|
|
176
|
-
record.state === "pending-until" &&
|
|
177
|
-
typeof record.token === "string" &&
|
|
178
|
-
typeof record.untilId === "string"
|
|
179
|
-
) {
|
|
180
|
-
const requestedSinceId =
|
|
181
|
-
"requestedSinceId" in record
|
|
182
|
-
? typeof record.requestedSinceId === "string"
|
|
183
|
-
? record.requestedSinceId
|
|
184
|
-
: null
|
|
185
|
-
: undefined;
|
|
186
|
-
return {
|
|
187
|
-
state: "pending-until",
|
|
188
|
-
sinceId,
|
|
189
|
-
token: record.token,
|
|
190
|
-
untilId: record.untilId,
|
|
191
|
-
...(requestedSinceId !== undefined ? { requestedSinceId } : {}),
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
const legacyToken =
|
|
195
|
-
typeof record.paginationToken === "string" ? record.paginationToken : null;
|
|
196
|
-
if (legacyToken) {
|
|
197
|
-
return {
|
|
198
|
-
state: "pending-forward",
|
|
199
|
-
sinceId,
|
|
200
|
-
token: legacyToken,
|
|
201
|
-
pendingNewestId:
|
|
202
|
-
typeof record.pendingNewestId === "string"
|
|
203
|
-
? record.pendingNewestId
|
|
204
|
-
: null,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return { state: "committed", sinceId };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function readAuthoredCursor(db: Database, accountId: string) {
|
|
211
|
-
return normalizeCursor(readSyncCache(cursorKey(accountId), db)?.value);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function writeAuthoredCursor(
|
|
215
|
-
db: Database,
|
|
216
|
-
accountId: string,
|
|
217
|
-
state: AuthoredCursorState,
|
|
218
|
-
) {
|
|
219
|
-
writeSyncCache(cursorKey(accountId), state, db);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function writeCommittedCursor(
|
|
223
|
-
db: Database,
|
|
224
|
-
accountId: string,
|
|
225
|
-
sinceId: string | null,
|
|
226
|
-
) {
|
|
227
|
-
writeAuthoredCursor(db, accountId, { state: "committed", sinceId });
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function writePendingForwardCursor(
|
|
231
|
-
db: Database,
|
|
232
|
-
accountId: string,
|
|
233
|
-
{
|
|
234
|
-
sinceId,
|
|
235
|
-
token,
|
|
236
|
-
pendingNewestId,
|
|
237
|
-
}: {
|
|
238
|
-
sinceId: string | null;
|
|
239
|
-
token: string;
|
|
240
|
-
pendingNewestId: string | null;
|
|
241
|
-
},
|
|
242
|
-
) {
|
|
243
|
-
writeAuthoredCursor(db, accountId, {
|
|
244
|
-
state: "pending-forward",
|
|
245
|
-
sinceId,
|
|
246
|
-
token,
|
|
247
|
-
pendingNewestId,
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
function writePendingUntilCursor(
|
|
252
|
-
db: Database,
|
|
253
|
-
accountId: string,
|
|
254
|
-
{
|
|
255
|
-
sinceId,
|
|
256
|
-
token,
|
|
257
|
-
untilId,
|
|
258
|
-
requestedSinceId,
|
|
259
|
-
}: {
|
|
260
|
-
sinceId: string | null;
|
|
261
|
-
token: string;
|
|
262
|
-
untilId: string;
|
|
263
|
-
requestedSinceId: string | null;
|
|
264
|
-
},
|
|
265
|
-
) {
|
|
266
|
-
writeAuthoredCursor(db, accountId, {
|
|
267
|
-
state: "pending-until",
|
|
268
|
-
sinceId,
|
|
269
|
-
token,
|
|
270
|
-
untilId,
|
|
271
|
-
requestedSinceId,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Archive seeds stay archive-only because backups can contain live edges without sync_cache.
|
|
276
|
-
function findArchiveAuthoredSinceSeed(db: Database, accountId: string) {
|
|
277
|
-
const row = db
|
|
278
|
-
.prepare(
|
|
279
|
-
`
|
|
280
|
-
select t.id
|
|
281
|
-
from tweets t
|
|
282
|
-
join accounts a on a.id = ?
|
|
283
|
-
where t.id glob '[0-9]*'
|
|
284
|
-
and t.id not glob '*[^0-9]*'
|
|
285
|
-
and (
|
|
286
|
-
exists (
|
|
287
|
-
select 1
|
|
288
|
-
from tweet_account_edges e
|
|
289
|
-
where e.account_id = ?
|
|
290
|
-
and e.tweet_id = t.id
|
|
291
|
-
and e.kind = 'authored'
|
|
292
|
-
and e.source = 'archive'
|
|
293
|
-
)
|
|
294
|
-
or (
|
|
295
|
-
t.kind = 'home'
|
|
296
|
-
and exists (
|
|
297
|
-
select 1
|
|
298
|
-
from tweet_account_edges e2
|
|
299
|
-
where e2.account_id = ?
|
|
300
|
-
and e2.tweet_id = t.id
|
|
301
|
-
and e2.source = 'archive'
|
|
302
|
-
and e2.kind = 'home'
|
|
303
|
-
)
|
|
304
|
-
and t.author_profile_id in ('profile_me', 'profile_user_' || a.external_user_id)
|
|
305
|
-
)
|
|
306
|
-
)
|
|
307
|
-
order by length(t.id) desc, t.id desc
|
|
308
|
-
limit 1
|
|
309
|
-
`,
|
|
310
|
-
)
|
|
311
|
-
.get(accountId, accountId, accountId) as { id: string } | undefined;
|
|
312
|
-
return row?.id ?? null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function compareTweetIds(
|
|
316
|
-
left: string | null | undefined,
|
|
317
|
-
right: string | null | undefined,
|
|
318
|
-
) {
|
|
319
|
-
if (!left && !right) {
|
|
320
|
-
return 0;
|
|
321
|
-
}
|
|
322
|
-
if (!left) {
|
|
323
|
-
return -1;
|
|
324
|
-
}
|
|
325
|
-
if (!right) {
|
|
326
|
-
return 1;
|
|
327
|
-
}
|
|
328
|
-
try {
|
|
329
|
-
const leftBigInt = BigInt(left);
|
|
330
|
-
const rightBigInt = BigInt(right);
|
|
331
|
-
return leftBigInt === rightBigInt ? 0 : leftBigInt > rightBigInt ? 1 : -1;
|
|
332
|
-
} catch {
|
|
333
|
-
if (left.length !== right.length) {
|
|
334
|
-
return left.length > right.length ? 1 : -1;
|
|
335
|
-
}
|
|
336
|
-
return left.localeCompare(right);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function maxTweetId(...ids: Array<string | null | undefined>) {
|
|
341
|
-
return ids.reduce<string | null>((current, next) => {
|
|
342
|
-
if (!next) {
|
|
343
|
-
return current;
|
|
344
|
-
}
|
|
345
|
-
return compareTweetIds(next, current) > 0 ? next : current;
|
|
346
|
-
}, null);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function getNewestTweetId(tweets: XurlMentionData[]) {
|
|
350
|
-
return maxTweetId(...tweets.map((tweet) => tweet.id));
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function getOldestTweetId(tweets: XurlMentionData[]) {
|
|
354
|
-
return tweets.reduce<string | null>((current, tweet) => {
|
|
355
|
-
if (!current) {
|
|
356
|
-
return tweet.id;
|
|
357
|
-
}
|
|
358
|
-
return compareTweetIds(tweet.id, current) < 0 ? tweet.id : current;
|
|
359
|
-
}, null);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function normalizeUsername(value: string) {
|
|
363
|
-
return value.replace(/^@/, "").trim().toLowerCase();
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function persistAccountExternalUserId(
|
|
367
|
-
db: Database,
|
|
368
|
-
accountId: string,
|
|
369
|
-
externalUserId: string,
|
|
370
|
-
) {
|
|
371
|
-
db.prepare(
|
|
372
|
-
`
|
|
373
|
-
update accounts
|
|
374
|
-
set external_user_id = ?
|
|
375
|
-
where id = ?
|
|
376
|
-
and (external_user_id is null or external_user_id = '')
|
|
377
|
-
`,
|
|
378
|
-
).run(externalUserId, accountId);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function userFromAuthenticatedPayload(
|
|
382
|
-
payload: Record<string, unknown> | null,
|
|
383
|
-
): XurlMentionUser | undefined {
|
|
384
|
-
if (!payload || typeof payload.id !== "string") {
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
const username =
|
|
388
|
-
typeof payload.username === "string"
|
|
389
|
-
? payload.username.replace(/^@/, "")
|
|
390
|
-
: "";
|
|
391
|
-
if (!username) {
|
|
392
|
-
return undefined;
|
|
393
|
-
}
|
|
394
|
-
return {
|
|
395
|
-
id: payload.id,
|
|
396
|
-
username,
|
|
397
|
-
name: typeof payload.name === "string" ? payload.name : username,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function resolveAuthoredIdentityEffect({
|
|
402
|
-
account,
|
|
403
|
-
db,
|
|
404
|
-
}: {
|
|
405
|
-
account?: string;
|
|
406
|
-
db: Database;
|
|
407
|
-
}) {
|
|
408
|
-
return Effect.gen(function* () {
|
|
409
|
-
const status = yield* liveTransportGateway.xurl.getTransportStatus();
|
|
410
|
-
if (status.availableTransport !== "xurl") {
|
|
411
|
-
return yield* Effect.fail(new AuthoredSyncError(status.statusText, 4));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
const resolvedAccount = yield* trySync(() =>
|
|
415
|
-
resolveLiveSyncAccount(db, account),
|
|
416
|
-
);
|
|
417
|
-
if (resolvedAccount.externalUserId) {
|
|
418
|
-
return {
|
|
419
|
-
...resolvedAccount,
|
|
420
|
-
userId: resolvedAccount.externalUserId,
|
|
421
|
-
authenticatedUser: undefined,
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const authenticated =
|
|
426
|
-
yield* liveTransportGateway.xurl.lookupAuthenticatedUser();
|
|
427
|
-
const authenticatedUser = userFromAuthenticatedPayload(authenticated);
|
|
428
|
-
if (!authenticatedUser?.id) {
|
|
429
|
-
return yield* Effect.fail(
|
|
430
|
-
new AuthoredSyncError(
|
|
431
|
-
"Could not resolve authenticated Twitter user id",
|
|
432
|
-
4,
|
|
433
|
-
),
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
normalizeUsername(authenticatedUser.username) !==
|
|
439
|
-
normalizeUsername(resolvedAccount.username)
|
|
440
|
-
) {
|
|
441
|
-
return yield* Effect.fail(
|
|
442
|
-
new AuthoredSyncError(
|
|
443
|
-
`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.`,
|
|
444
|
-
4,
|
|
445
|
-
),
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
yield* trySync(() =>
|
|
450
|
-
persistAccountExternalUserId(
|
|
451
|
-
db,
|
|
452
|
-
resolvedAccount.accountId,
|
|
453
|
-
authenticatedUser.id,
|
|
454
|
-
),
|
|
455
|
-
);
|
|
456
|
-
|
|
457
|
-
return {
|
|
458
|
-
...resolvedAccount,
|
|
459
|
-
userId: authenticatedUser.id,
|
|
460
|
-
authenticatedUser,
|
|
461
|
-
};
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function toFallbackUser({
|
|
466
|
-
userId,
|
|
467
|
-
username,
|
|
468
|
-
authenticatedUser,
|
|
469
|
-
}: {
|
|
470
|
-
userId: string;
|
|
471
|
-
username: string;
|
|
472
|
-
authenticatedUser?: XurlMentionUser;
|
|
473
|
-
}): XurlMentionUser {
|
|
474
|
-
if (authenticatedUser?.id === userId) {
|
|
475
|
-
return authenticatedUser;
|
|
476
|
-
}
|
|
477
|
-
return {
|
|
478
|
-
id: userId,
|
|
479
|
-
username: username || `user_${userId}`,
|
|
480
|
-
name: username || `user_${userId}`,
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function toMentionData(tweet: XurlUserTweet, fallbackAuthorId: string) {
|
|
485
|
-
return {
|
|
486
|
-
...tweet,
|
|
487
|
-
author_id: tweet.author_id ?? fallbackAuthorId,
|
|
488
|
-
} satisfies XurlMentionData;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function toLocalEntities(tweet: XurlMentionData): TweetEntities {
|
|
492
|
-
return tweetEntitiesFromXurl(tweet.entities);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function toMediaType(type: string): TweetMediaItem["type"] {
|
|
496
|
-
if (type === "photo" || type === "image") {
|
|
497
|
-
return "image";
|
|
498
|
-
}
|
|
499
|
-
if (type === "animated_gif" || type === "gif") {
|
|
500
|
-
return "gif";
|
|
501
|
-
}
|
|
502
|
-
return type === "video" ? "video" : "unknown";
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
function toLocalMedia(
|
|
506
|
-
tweet: XurlMentionData,
|
|
507
|
-
mediaByKey: Map<string, XurlMedia>,
|
|
508
|
-
) {
|
|
509
|
-
const mediaKeys = tweet.attachments?.media_keys ?? [];
|
|
510
|
-
const seen = new Set<string>();
|
|
511
|
-
return mediaKeys
|
|
512
|
-
.map((mediaKey) => mediaByKey.get(mediaKey))
|
|
513
|
-
.filter((media): media is XurlMedia => Boolean(media))
|
|
514
|
-
.map((media) => {
|
|
515
|
-
const url = media.url ?? media.preview_image_url ?? "";
|
|
516
|
-
const thumbnailUrl = media.preview_image_url ?? media.url ?? "";
|
|
517
|
-
return {
|
|
518
|
-
url,
|
|
519
|
-
type: toMediaType(media.type),
|
|
520
|
-
...(typeof media.alt_text === "string"
|
|
521
|
-
? { altText: media.alt_text }
|
|
522
|
-
: {}),
|
|
523
|
-
...(typeof media.width === "number" ? { width: media.width } : {}),
|
|
524
|
-
...(typeof media.height === "number" ? { height: media.height } : {}),
|
|
525
|
-
...(thumbnailUrl ? { thumbnailUrl } : {}),
|
|
526
|
-
};
|
|
527
|
-
})
|
|
528
|
-
.filter((media) => {
|
|
529
|
-
const key = media.url || media.thumbnailUrl;
|
|
530
|
-
if (!key || seen.has(key)) {
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
seen.add(key);
|
|
534
|
-
return true;
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function getMediaCount(tweet: XurlMentionData, media: TweetMediaItem[]) {
|
|
539
|
-
if (media.length > 0) {
|
|
540
|
-
return media.length;
|
|
541
|
-
}
|
|
542
|
-
if (tweet.attachments?.media_keys?.length) {
|
|
543
|
-
return tweet.attachments.media_keys.length;
|
|
544
|
-
}
|
|
545
|
-
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
546
|
-
return urls.filter(
|
|
547
|
-
(url) =>
|
|
548
|
-
url &&
|
|
549
|
-
typeof url === "object" &&
|
|
550
|
-
typeof (url as Record<string, unknown>).media_key === "string",
|
|
551
|
-
).length;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function getReferencedTweetId(tweet: XurlMentionData, type: string) {
|
|
555
|
-
return (
|
|
556
|
-
tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
561
|
-
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
562
|
-
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
563
|
-
tweetId,
|
|
564
|
-
text,
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function findExistingProfileIdForUser(db: Database, user: XurlMentionUser) {
|
|
569
|
-
const username = String(user.username ?? "").replace(/^@/, "");
|
|
570
|
-
const row = db
|
|
571
|
-
.prepare(
|
|
572
|
-
`
|
|
573
|
-
select id
|
|
574
|
-
from profiles
|
|
575
|
-
where id = ? or handle = ?
|
|
576
|
-
limit 1
|
|
577
|
-
`,
|
|
578
|
-
)
|
|
579
|
-
.get(buildExternalProfileId(user.id), username) as
|
|
580
|
-
| { id: string }
|
|
581
|
-
| undefined;
|
|
582
|
-
return row?.id ?? null;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function mergeAuthoredPayloadIntoLocalStore({
|
|
586
|
-
db,
|
|
587
|
-
accountId,
|
|
588
|
-
payload,
|
|
589
|
-
sourceUser,
|
|
590
|
-
}: {
|
|
591
|
-
db: Database;
|
|
592
|
-
accountId: string;
|
|
593
|
-
payload: AuthoredPayload;
|
|
594
|
-
sourceUser: XurlMentionUser;
|
|
595
|
-
}) {
|
|
596
|
-
const usersById = new Map(
|
|
597
|
-
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
598
|
-
);
|
|
599
|
-
const fallbackUserIds = new Set<string>();
|
|
600
|
-
if (!usersById.has(sourceUser.id)) {
|
|
601
|
-
usersById.set(sourceUser.id, sourceUser);
|
|
602
|
-
fallbackUserIds.add(sourceUser.id);
|
|
603
|
-
}
|
|
604
|
-
const mediaByKey = new Map(
|
|
605
|
-
(payload.includes?.media ?? []).map((media) => [media.media_key, media]),
|
|
606
|
-
);
|
|
607
|
-
const upsertTweet = db.prepare(
|
|
608
|
-
`
|
|
609
|
-
insert into tweets (
|
|
610
|
-
id, account_id, author_profile_id, kind, text, created_at,
|
|
611
|
-
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
612
|
-
entities_json, media_json, quoted_tweet_id
|
|
613
|
-
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
614
|
-
on conflict(id) do update set
|
|
615
|
-
account_id = tweets.account_id,
|
|
616
|
-
author_profile_id = excluded.author_profile_id,
|
|
617
|
-
kind = case
|
|
618
|
-
when tweets.kind in ('home', 'mention', 'authored') then tweets.kind
|
|
619
|
-
when excluded.kind in ('home', 'mention', 'authored') then excluded.kind
|
|
620
|
-
else coalesce(nullif(tweets.kind, ''), excluded.kind)
|
|
621
|
-
end,
|
|
622
|
-
text = excluded.text,
|
|
623
|
-
created_at = excluded.created_at,
|
|
624
|
-
like_count = excluded.like_count,
|
|
625
|
-
media_count = excluded.media_count,
|
|
626
|
-
entities_json = excluded.entities_json,
|
|
627
|
-
media_json = excluded.media_json,
|
|
628
|
-
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
629
|
-
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
630
|
-
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
|
|
631
|
-
bookmarked = tweets.bookmarked,
|
|
632
|
-
liked = tweets.liked
|
|
633
|
-
`,
|
|
634
|
-
);
|
|
635
|
-
|
|
636
|
-
const writeTweet = (tweet: XurlMentionData, kind: "authored" | "thread") => {
|
|
637
|
-
const author =
|
|
638
|
-
usersById.get(tweet.author_id) ??
|
|
639
|
-
({
|
|
640
|
-
id: tweet.author_id,
|
|
641
|
-
username: `user_${tweet.author_id}`,
|
|
642
|
-
name: `user_${tweet.author_id}`,
|
|
643
|
-
} as const);
|
|
644
|
-
const profileId =
|
|
645
|
-
usersById.has(tweet.author_id) && !fallbackUserIds.has(tweet.author_id)
|
|
646
|
-
? upsertProfileFromXUser(db, author).profile.id
|
|
647
|
-
: ((fallbackUserIds.has(tweet.author_id)
|
|
648
|
-
? findExistingProfileIdForUser(db, author)
|
|
649
|
-
: null) ??
|
|
650
|
-
ensureStubProfileForXUser(db, tweet.author_id).profile.id);
|
|
651
|
-
const replyToId = getReferencedTweetId(tweet, "replied_to");
|
|
652
|
-
const quotedTweetId = getReferencedTweetId(tweet, "quoted");
|
|
653
|
-
const media = toLocalMedia(tweet, mediaByKey);
|
|
654
|
-
upsertTweet.run(
|
|
655
|
-
tweet.id,
|
|
656
|
-
accountId,
|
|
657
|
-
profileId,
|
|
658
|
-
kind,
|
|
659
|
-
tweet.text,
|
|
660
|
-
tweet.created_at,
|
|
661
|
-
replyToId ? 1 : 0,
|
|
662
|
-
replyToId,
|
|
663
|
-
Number(tweet.public_metrics?.like_count ?? 0),
|
|
664
|
-
getMediaCount(tweet, media),
|
|
665
|
-
0,
|
|
666
|
-
0,
|
|
667
|
-
JSON.stringify(toLocalEntities(tweet)),
|
|
668
|
-
JSON.stringify(media),
|
|
669
|
-
quotedTweetId,
|
|
670
|
-
);
|
|
671
|
-
replaceTweetFts(db, tweet.id, tweet.text);
|
|
672
|
-
};
|
|
673
|
-
|
|
674
|
-
db.transaction(() => {
|
|
675
|
-
const seenAt = new Date().toISOString();
|
|
676
|
-
for (const includedTweet of payload.includes?.tweets ?? []) {
|
|
677
|
-
writeTweet(includedTweet, "thread");
|
|
678
|
-
}
|
|
679
|
-
for (const tweet of payload.data) {
|
|
680
|
-
writeTweet(tweet, "authored");
|
|
681
|
-
upsertTweetAccountEdge(db, {
|
|
682
|
-
accountId,
|
|
683
|
-
tweetId: tweet.id,
|
|
684
|
-
kind: "authored",
|
|
685
|
-
source: "xurl",
|
|
686
|
-
seenAt,
|
|
687
|
-
rawJson: JSON.stringify(tweet),
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
})();
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
function appendUniqueById<T extends { id: string }>(
|
|
694
|
-
target: T[],
|
|
695
|
-
seenIds: Set<string>,
|
|
696
|
-
items: T[] | undefined,
|
|
697
|
-
) {
|
|
698
|
-
for (const item of items ?? []) {
|
|
699
|
-
if (seenIds.has(item.id)) {
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
seenIds.add(item.id);
|
|
703
|
-
target.push(item);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
function appendUniqueMedia(
|
|
708
|
-
target: XurlMedia[],
|
|
709
|
-
seenIds: Set<string>,
|
|
710
|
-
items: XurlMedia[] | undefined,
|
|
711
|
-
) {
|
|
712
|
-
for (const item of items ?? []) {
|
|
713
|
-
if (seenIds.has(item.media_key)) {
|
|
714
|
-
continue;
|
|
715
|
-
}
|
|
716
|
-
seenIds.add(item.media_key);
|
|
717
|
-
target.push(item);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
function mergePages({
|
|
722
|
-
pages,
|
|
723
|
-
userId,
|
|
724
|
-
nextToken,
|
|
725
|
-
}: {
|
|
726
|
-
pages: XurlUserTweetsResponse[];
|
|
727
|
-
userId: string;
|
|
728
|
-
nextToken: string | null;
|
|
729
|
-
}): AuthoredPayload {
|
|
730
|
-
const tweets: XurlMentionData[] = [];
|
|
731
|
-
const users: XurlMentionUser[] = [];
|
|
732
|
-
const includedTweets: XurlTweetData[] = [];
|
|
733
|
-
const media: XurlMedia[] = [];
|
|
734
|
-
const seenTweetIds = new Set<string>();
|
|
735
|
-
const seenUserIds = new Set<string>();
|
|
736
|
-
const seenIncludedTweetIds = new Set<string>();
|
|
737
|
-
const seenMediaKeys = new Set<string>();
|
|
738
|
-
|
|
739
|
-
for (const page of pages) {
|
|
740
|
-
for (const tweet of page.items) {
|
|
741
|
-
const normalized = toMentionData(tweet, userId);
|
|
742
|
-
if (seenTweetIds.has(normalized.id)) {
|
|
743
|
-
continue;
|
|
744
|
-
}
|
|
745
|
-
seenTweetIds.add(normalized.id);
|
|
746
|
-
tweets.push(normalized);
|
|
747
|
-
}
|
|
748
|
-
appendUniqueById(users, seenUserIds, page.includes?.users);
|
|
749
|
-
appendUniqueById(
|
|
750
|
-
includedTweets,
|
|
751
|
-
seenIncludedTweetIds,
|
|
752
|
-
page.includes?.tweets,
|
|
753
|
-
);
|
|
754
|
-
appendUniqueMedia(media, seenMediaKeys, page.includes?.media);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
const newestId = getNewestTweetId(tweets);
|
|
758
|
-
const oldestId = getOldestTweetId(tweets);
|
|
759
|
-
const includes = {
|
|
760
|
-
...(users.length > 0 ? { users } : {}),
|
|
761
|
-
...(includedTweets.length > 0 ? { tweets: includedTweets } : {}),
|
|
762
|
-
...(media.length > 0 ? { media } : {}),
|
|
763
|
-
};
|
|
764
|
-
|
|
765
|
-
return {
|
|
766
|
-
data: tweets,
|
|
767
|
-
...(Object.keys(includes).length > 0 ? { includes } : {}),
|
|
768
|
-
meta: {
|
|
769
|
-
result_count: tweets.length,
|
|
770
|
-
page_count: pages.length,
|
|
771
|
-
next_token: nextToken,
|
|
772
|
-
...(newestId ? { newest_id: newestId } : {}),
|
|
773
|
-
...(oldestId ? { oldest_id: oldestId } : {}),
|
|
774
|
-
},
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function formatError(error: unknown) {
|
|
779
|
-
return error instanceof Error ? error.message : String(error);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function buildResult({
|
|
783
|
-
accountId,
|
|
784
|
-
userId,
|
|
785
|
-
effectiveSinceId,
|
|
786
|
-
nextSinceId,
|
|
787
|
-
nextToken,
|
|
788
|
-
pageCount,
|
|
789
|
-
payload,
|
|
790
|
-
partial,
|
|
791
|
-
error,
|
|
792
|
-
}: {
|
|
793
|
-
accountId: string;
|
|
794
|
-
userId: string;
|
|
795
|
-
effectiveSinceId: string | null;
|
|
796
|
-
nextSinceId: string | null;
|
|
797
|
-
nextToken: string | null;
|
|
798
|
-
pageCount: number;
|
|
799
|
-
payload: AuthoredPayload;
|
|
800
|
-
partial: boolean;
|
|
801
|
-
error?: string;
|
|
802
|
-
}) {
|
|
803
|
-
return {
|
|
804
|
-
ok: !partial,
|
|
805
|
-
kind: "authored" as const,
|
|
806
|
-
source: "xurl" as const,
|
|
807
|
-
accountId,
|
|
808
|
-
userId,
|
|
809
|
-
count: payload.data.length,
|
|
810
|
-
pages: pageCount,
|
|
811
|
-
sinceId: effectiveSinceId,
|
|
812
|
-
nextSinceId,
|
|
813
|
-
nextToken,
|
|
814
|
-
partial,
|
|
815
|
-
...(error ? { error } : {}),
|
|
816
|
-
cursor: {
|
|
817
|
-
sinceId: nextSinceId,
|
|
818
|
-
paginationToken: nextToken,
|
|
819
|
-
pending: Boolean(nextToken),
|
|
820
|
-
},
|
|
821
|
-
payload,
|
|
822
|
-
};
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
export function syncAuthoredTweetsEffect({
|
|
826
|
-
account,
|
|
827
|
-
mode = "xurl",
|
|
828
|
-
limit = DEFAULT_LIMIT,
|
|
829
|
-
maxPages,
|
|
830
|
-
sinceId,
|
|
831
|
-
untilId,
|
|
832
|
-
}: SyncAuthoredTweetsOptions) {
|
|
833
|
-
return Effect.gen(function* () {
|
|
834
|
-
if (mode !== "xurl") {
|
|
835
|
-
return yield* Effect.fail(
|
|
836
|
-
new Error("authored sync only supports --mode xurl"),
|
|
837
|
-
);
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const pageLimit = yield* trySync(() => assertXurlLimit(limit));
|
|
841
|
-
const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
|
|
842
|
-
const db = yield* trySync(() => getNativeDb());
|
|
843
|
-
const identity = yield* resolveAuthoredIdentityEffect({ account, db });
|
|
844
|
-
const cursor = yield* trySync(() =>
|
|
845
|
-
readAuthoredCursor(db, identity.accountId),
|
|
846
|
-
);
|
|
847
|
-
const usePersistedForward =
|
|
848
|
-
sinceId === undefined && !untilId && cursor.state === "pending-forward";
|
|
849
|
-
const usePersistedUntil =
|
|
850
|
-
sinceId === undefined &&
|
|
851
|
-
Boolean(untilId) &&
|
|
852
|
-
cursor.state === "pending-until" &&
|
|
853
|
-
cursor.untilId === untilId;
|
|
854
|
-
const shouldSeedFromArchive =
|
|
855
|
-
!usePersistedForward &&
|
|
856
|
-
!cursor.sinceId &&
|
|
857
|
-
sinceId === undefined &&
|
|
858
|
-
!untilId;
|
|
859
|
-
const archiveSinceSeed = shouldSeedFromArchive
|
|
860
|
-
? yield* trySync(() =>
|
|
861
|
-
findArchiveAuthoredSinceSeed(db, identity.accountId),
|
|
862
|
-
)
|
|
863
|
-
: null;
|
|
864
|
-
if (shouldSeedFromArchive && !archiveSinceSeed) {
|
|
865
|
-
console.error(
|
|
866
|
-
"birdclaw sync authored: no archive baseline found; starting a full backwards scan",
|
|
867
|
-
);
|
|
868
|
-
}
|
|
869
|
-
const persistedUntilSinceId: string | null = usePersistedUntil
|
|
870
|
-
? (("requestedSinceId" in cursor
|
|
871
|
-
? cursor.requestedSinceId
|
|
872
|
-
: cursor.sinceId) ?? null)
|
|
873
|
-
: null;
|
|
874
|
-
const effectiveSinceId: string | null =
|
|
875
|
-
sinceId ??
|
|
876
|
-
archiveSinceSeed ??
|
|
877
|
-
(untilId ? persistedUntilSinceId : cursor.sinceId) ??
|
|
878
|
-
null;
|
|
879
|
-
const initialToken = usePersistedForward
|
|
880
|
-
? cursor.token
|
|
881
|
-
: usePersistedUntil
|
|
882
|
-
? cursor.token
|
|
883
|
-
: undefined;
|
|
884
|
-
let newestSeenId = usePersistedForward
|
|
885
|
-
? maxTweetId(cursor.sinceId, cursor.pendingNewestId)
|
|
886
|
-
: cursor.sinceId;
|
|
887
|
-
const sourceUser = toFallbackUser({
|
|
888
|
-
userId: identity.userId,
|
|
889
|
-
username: identity.username,
|
|
890
|
-
authenticatedUser: identity.authenticatedUser,
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
const planResult = yield* runSyncPlanEffect({
|
|
894
|
-
allowPartialFailure: true,
|
|
895
|
-
initialCursor: initialToken,
|
|
896
|
-
maxPages: parsedMaxPages ?? undefined,
|
|
897
|
-
fetchPage: ({ cursor: paginationToken }) =>
|
|
898
|
-
liveTransportGateway.xurl.listUserTweets(identity.userId, {
|
|
899
|
-
maxResults: pageLimit,
|
|
900
|
-
paginationToken,
|
|
901
|
-
excludeRetweets: false,
|
|
902
|
-
sinceId: effectiveSinceId ?? undefined,
|
|
903
|
-
untilId,
|
|
904
|
-
tweetFields: AUTHORED_TWEET_FIELDS,
|
|
905
|
-
expansions: AUTHORED_EXPANSIONS,
|
|
906
|
-
userFields: AUTHORED_USER_FIELDS,
|
|
907
|
-
mediaFields: AUTHORED_MEDIA_FIELDS,
|
|
908
|
-
auth: "oauth2",
|
|
909
|
-
username: identity.username,
|
|
910
|
-
}),
|
|
911
|
-
getNextCursor: (page) => page.nextToken,
|
|
912
|
-
persistPage: ({ page, nextCursor: pendingToken }) => {
|
|
913
|
-
const pagePayload = mergePages({
|
|
914
|
-
pages: [page],
|
|
915
|
-
userId: identity.userId,
|
|
916
|
-
nextToken: page.nextToken,
|
|
917
|
-
});
|
|
918
|
-
return databaseWriteEffect((writeDb) =>
|
|
919
|
-
mergeAuthoredPayloadIntoLocalStore({
|
|
920
|
-
db: writeDb,
|
|
921
|
-
accountId: identity.accountId,
|
|
922
|
-
payload: pagePayload,
|
|
923
|
-
sourceUser,
|
|
924
|
-
}),
|
|
925
|
-
).pipe(
|
|
926
|
-
Effect.flatMap(() =>
|
|
927
|
-
trySync(() => {
|
|
928
|
-
newestSeenId = maxTweetId(
|
|
929
|
-
newestSeenId,
|
|
930
|
-
pagePayload.meta.newest_id,
|
|
931
|
-
);
|
|
932
|
-
if (pendingToken && untilId) {
|
|
933
|
-
writePendingUntilCursor(db, identity.accountId, {
|
|
934
|
-
sinceId: cursor.sinceId,
|
|
935
|
-
token: pendingToken,
|
|
936
|
-
untilId,
|
|
937
|
-
requestedSinceId: effectiveSinceId,
|
|
938
|
-
});
|
|
939
|
-
} else if (pendingToken) {
|
|
940
|
-
writePendingForwardCursor(db, identity.accountId, {
|
|
941
|
-
sinceId: effectiveSinceId,
|
|
942
|
-
token: pendingToken,
|
|
943
|
-
pendingNewestId: newestSeenId,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
}),
|
|
947
|
-
),
|
|
948
|
-
);
|
|
949
|
-
},
|
|
950
|
-
});
|
|
951
|
-
const pages = planResult.pages;
|
|
952
|
-
const pageCount = pages.length;
|
|
953
|
-
const nextToken = planResult.nextCursor;
|
|
954
|
-
if (planResult.stopReason === "error") {
|
|
955
|
-
const payload = mergePages({
|
|
956
|
-
pages,
|
|
957
|
-
userId: identity.userId,
|
|
958
|
-
nextToken: nextToken ?? null,
|
|
959
|
-
});
|
|
960
|
-
return buildResult({
|
|
961
|
-
accountId: identity.accountId,
|
|
962
|
-
userId: identity.userId,
|
|
963
|
-
effectiveSinceId,
|
|
964
|
-
nextSinceId: untilId ? cursor.sinceId : effectiveSinceId,
|
|
965
|
-
nextToken: nextToken ?? null,
|
|
966
|
-
pageCount,
|
|
967
|
-
payload,
|
|
968
|
-
partial: true,
|
|
969
|
-
error: formatError(planResult.error),
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const capped = Boolean(nextToken);
|
|
974
|
-
const nextSinceId = untilId
|
|
975
|
-
? cursor.sinceId
|
|
976
|
-
: capped
|
|
977
|
-
? effectiveSinceId
|
|
978
|
-
: maxTweetId(newestSeenId, effectiveSinceId, cursor.sinceId);
|
|
979
|
-
if (untilId && capped && nextToken) {
|
|
980
|
-
yield* trySync(() =>
|
|
981
|
-
writePendingUntilCursor(db, identity.accountId, {
|
|
982
|
-
sinceId: cursor.sinceId,
|
|
983
|
-
token: nextToken,
|
|
984
|
-
untilId,
|
|
985
|
-
requestedSinceId: effectiveSinceId,
|
|
986
|
-
}),
|
|
987
|
-
);
|
|
988
|
-
} else if (untilId) {
|
|
989
|
-
yield* trySync(() =>
|
|
990
|
-
writeCommittedCursor(db, identity.accountId, cursor.sinceId),
|
|
991
|
-
);
|
|
992
|
-
} else if (capped && nextToken) {
|
|
993
|
-
yield* trySync(() =>
|
|
994
|
-
writePendingForwardCursor(db, identity.accountId, {
|
|
995
|
-
sinceId: nextSinceId,
|
|
996
|
-
token: nextToken,
|
|
997
|
-
pendingNewestId: newestSeenId,
|
|
998
|
-
}),
|
|
999
|
-
);
|
|
1000
|
-
} else {
|
|
1001
|
-
yield* trySync(() =>
|
|
1002
|
-
writeCommittedCursor(db, identity.accountId, nextSinceId),
|
|
1003
|
-
);
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
const payload = mergePages({
|
|
1007
|
-
pages,
|
|
1008
|
-
userId: identity.userId,
|
|
1009
|
-
nextToken: nextToken ?? null,
|
|
1010
|
-
});
|
|
1011
|
-
return buildResult({
|
|
1012
|
-
accountId: identity.accountId,
|
|
1013
|
-
userId: identity.userId,
|
|
1014
|
-
effectiveSinceId,
|
|
1015
|
-
nextSinceId,
|
|
1016
|
-
nextToken: nextToken ?? null,
|
|
1017
|
-
pageCount,
|
|
1018
|
-
payload,
|
|
1019
|
-
partial: capped,
|
|
1020
|
-
...(capped ? { error: "max pages reached before sync completed" } : {}),
|
|
1021
|
-
});
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
export function syncAuthoredTweets(options: SyncAuthoredTweetsOptions) {
|
|
1026
|
-
return runEffectPromise(syncAuthoredTweetsEffect(options));
|
|
1027
|
-
}
|