birdclaw 0.6.0 → 0.8.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +32 -2
  3. package/package.json +29 -30
  4. package/scripts/build-docs-site.mjs +39 -12
  5. package/src/cli.ts +457 -26
  6. package/src/components/AppNav.tsx +10 -0
  7. package/src/components/MarkdownViewer.tsx +438 -72
  8. package/src/components/ProfileAnalysisStream.tsx +428 -0
  9. package/src/components/ProfilePreview.tsx +120 -9
  10. package/src/components/SavedTimelineView.tsx +30 -8
  11. package/src/components/SyncNowButton.tsx +5 -2
  12. package/src/components/TimelineCard.tsx +20 -4
  13. package/src/components/TimelineRouteFrame.tsx +16 -0
  14. package/src/components/TweetRichText.tsx +36 -12
  15. package/src/components/useTimelineRouteData.ts +74 -6
  16. package/src/lib/account-sync-job.ts +15 -3
  17. package/src/lib/archive-finder.ts +1 -1
  18. package/src/lib/archive-import.ts +245 -7
  19. package/src/lib/authored-live.ts +1 -0
  20. package/src/lib/avatar-cache.ts +50 -0
  21. package/src/lib/backup.ts +4 -3
  22. package/src/lib/bird.ts +33 -0
  23. package/src/lib/config.ts +35 -2
  24. package/src/lib/data-sources.ts +219 -0
  25. package/src/lib/db.ts +62 -1
  26. package/src/lib/geocoding.ts +296 -0
  27. package/src/lib/link-insights.ts +2 -0
  28. package/src/lib/location.ts +137 -0
  29. package/src/lib/mention-threads-live.ts +94 -1
  30. package/src/lib/mentions-live.ts +187 -40
  31. package/src/lib/network-map.ts +382 -0
  32. package/src/lib/period-digest.ts +468 -29
  33. package/src/lib/profile-analysis.ts +1272 -0
  34. package/src/lib/profile-bio-entities.ts +1 -1
  35. package/src/lib/queries.ts +14 -4
  36. package/src/lib/search-discussion.ts +1016 -0
  37. package/src/lib/timeline-live.ts +272 -19
  38. package/src/lib/tweet-account-edges.ts +2 -0
  39. package/src/lib/tweet-render.ts +141 -1
  40. package/src/lib/tweet-search-live.ts +565 -0
  41. package/src/lib/types.ts +37 -2
  42. package/src/lib/ui.ts +1 -1
  43. package/src/lib/web-sync.ts +7 -2
  44. package/src/lib/xurl-rate-limits.ts +267 -0
  45. package/src/lib/xurl.ts +551 -41
  46. package/src/routeTree.gen.ts +231 -0
  47. package/src/routes/__root.tsx +5 -6
  48. package/src/routes/api/data-sources.tsx +24 -0
  49. package/src/routes/api/network-map.tsx +55 -0
  50. package/src/routes/api/period-digest.tsx +29 -3
  51. package/src/routes/api/profile-analysis.tsx +152 -0
  52. package/src/routes/api/query.tsx +1 -0
  53. package/src/routes/api/search-discussion.tsx +169 -0
  54. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  55. package/src/routes/data-sources.tsx +257 -0
  56. package/src/routes/discuss.tsx +419 -0
  57. package/src/routes/network-map.tsx +1035 -0
  58. package/src/routes/profile-analyze.tsx +112 -0
  59. package/src/routes/profiles.$handle.tsx +228 -0
  60. package/src/routes/rate-limits.tsx +309 -0
  61. package/src/routes/today.tsx +22 -8
  62. package/src/styles.css +22 -0
@@ -1,6 +1,9 @@
1
1
  import type { Database } from "./sqlite";
2
2
  import { Effect } from "effect";
3
- import { listMentionsViaBirdEffect } from "./bird";
3
+ import {
4
+ getAuthenticatedBirdAccountEffect,
5
+ listMentionsViaBirdEffect,
6
+ } from "./bird";
4
7
  import type { MentionsDataSource } from "./config";
5
8
  import { getNativeDb } from "./db";
6
9
  import { runEffectPromise, tryPromise } from "./effect-runtime";
@@ -24,6 +27,16 @@ export const DEFAULT_MENTIONS_CACHE_TTL_MS = 2 * 60_000;
24
27
  const MIN_XURL_MENTIONS_LIMIT = 5;
25
28
  const MAX_XURL_MENTIONS_LIMIT = 100;
26
29
  type MentionSyncMode = Exclude<MentionsDataSource, "birdclaw">;
30
+ type MentionLiveSource = Exclude<MentionSyncMode, "auto">;
31
+ export interface MentionsProgress {
32
+ source: "bird" | "xurl" | "cache";
33
+ fetched: number;
34
+ total?: number;
35
+ page?: number;
36
+ maxPages?: number;
37
+ pageSize?: number;
38
+ done: boolean;
39
+ }
27
40
  export interface SyncMentionsOptions {
28
41
  account?: string;
29
42
  mode?: string;
@@ -33,9 +46,10 @@ export interface SyncMentionsOptions {
33
46
  cacheTtlMs?: number;
34
47
  sinceId?: string;
35
48
  startTime?: string;
49
+ onProgress?: (progress: MentionsProgress) => void;
36
50
  }
37
51
  interface ExportMentionsViaCachedLiveSourceOptions {
38
- mode: MentionsDataSource;
52
+ mode: MentionSyncMode;
39
53
  account?: string;
40
54
  search?: string;
41
55
  replyFilter?: ReplyFilter;
@@ -52,7 +66,7 @@ type MentionScanBoundary =
52
66
  | { kind: "unbounded" };
53
67
  interface MentionScanShape {
54
68
  endpoint: "mentions";
55
- mode: MentionSyncMode;
69
+ mode: MentionLiveSource;
56
70
  accountId: string;
57
71
  pageSize: number;
58
72
  boundary: MentionScanBoundary;
@@ -167,7 +181,7 @@ function getMentionHighWaterKey({
167
181
  mode,
168
182
  accountId,
169
183
  }: {
170
- mode: MentionSyncMode;
184
+ mode: MentionLiveSource;
171
185
  accountId: string;
172
186
  }) {
173
187
  return `mentions:sync:high-water:v1:mode=${mode}:account=${encodeCacheKeyPart(accountId)}`;
@@ -268,9 +282,9 @@ function parseMaxPages(value?: number) {
268
282
  }
269
283
 
270
284
  function parseSyncMode(value?: string): MentionSyncMode {
271
- const mode = value ?? "xurl";
272
- if (mode !== "bird" && mode !== "xurl") {
273
- throw new Error("--mode must be bird or xurl");
285
+ const mode = value ?? "auto";
286
+ if (mode !== "auto" && mode !== "bird" && mode !== "xurl") {
287
+ throw new Error("--mode must be auto, bird, or xurl");
274
288
  }
275
289
  return mode;
276
290
  }
@@ -392,7 +406,7 @@ function getNewestMentionId(payload: XurlMentionsResponse) {
392
406
 
393
407
  function readMentionHighWaterId(
394
408
  db: Database,
395
- mode: MentionSyncMode,
409
+ mode: MentionLiveSource,
396
410
  accountId: string,
397
411
  ) {
398
412
  const cached = readSyncCache<MentionHighWaterValue>(
@@ -406,7 +420,7 @@ function readMentionHighWaterId(
406
420
 
407
421
  function writeMentionHighWaterId(
408
422
  db: Database,
409
- mode: MentionSyncMode,
423
+ mode: MentionLiveSource,
410
424
  accountId: string,
411
425
  sinceId: string | undefined,
412
426
  ) {
@@ -419,18 +433,24 @@ function writeMentionHighWaterId(
419
433
  function resolveAccount(db: Database, accountId?: string) {
420
434
  const row = accountId
421
435
  ? (db
422
- .prepare("select id, handle from accounts where id = ?")
423
- .get(accountId) as { id: string; handle: string } | undefined)
436
+ .prepare(
437
+ "select id, handle, external_user_id from accounts where id = ?",
438
+ )
439
+ .get(accountId) as
440
+ | { id: string; handle: string; external_user_id: string | null }
441
+ | undefined)
424
442
  : (db
425
443
  .prepare(
426
444
  `
427
- select id, handle
445
+ select id, handle, external_user_id
428
446
  from accounts
429
447
  order by is_default desc, created_at asc
430
448
  limit 1
431
449
  `,
432
450
  )
433
- .get() as { id: string; handle: string } | undefined);
451
+ .get() as
452
+ | { id: string; handle: string; external_user_id: string | null }
453
+ | undefined);
434
454
 
435
455
  if (!row) {
436
456
  throw new Error(`Unknown account: ${accountId ?? "default"}`);
@@ -439,9 +459,45 @@ function resolveAccount(db: Database, accountId?: string) {
439
459
  return {
440
460
  accountId: row.id,
441
461
  username: row.handle.replace(/^@/, ""),
462
+ externalUserId:
463
+ typeof row.external_user_id === "string" &&
464
+ row.external_user_id.trim().length > 0
465
+ ? row.external_user_id.trim()
466
+ : undefined,
442
467
  };
443
468
  }
444
469
 
470
+ function verifyBirdAccountMatchesEffect({
471
+ accountId,
472
+ username,
473
+ externalUserId,
474
+ }: ReturnType<typeof resolveAccount>) {
475
+ return Effect.gen(function* () {
476
+ const authenticated = yield* getAuthenticatedBirdAccountEffect();
477
+ if (
478
+ externalUserId &&
479
+ authenticated.id &&
480
+ authenticated.id === externalUserId
481
+ ) {
482
+ return;
483
+ }
484
+ if (externalUserId && authenticated.id) {
485
+ return yield* Effect.fail(
486
+ new Error(
487
+ `bird is authenticated as user ${authenticated.id}; refusing to sync into ${accountId} (${externalUserId})`,
488
+ ),
489
+ );
490
+ }
491
+ if (authenticated.username.toLowerCase() !== username.toLowerCase()) {
492
+ return yield* Effect.fail(
493
+ new Error(
494
+ `bird is authenticated as @${authenticated.username}; refusing to sync into ${accountId} (@${username})`,
495
+ ),
496
+ );
497
+ }
498
+ });
499
+ }
500
+
445
501
  function findNewestArchiveMentionId(db: Database, accountId: string) {
446
502
  const row = db
447
503
  .prepare(
@@ -697,6 +753,7 @@ function fetchMentionsViaXurlEffect({
697
753
  sinceId,
698
754
  startPaginationToken,
699
755
  startTime,
756
+ onProgress,
700
757
  }: {
701
758
  resolvedAccount: ReturnType<typeof resolveAccount>;
702
759
  limit: number;
@@ -705,12 +762,15 @@ function fetchMentionsViaXurlEffect({
705
762
  sinceId?: string;
706
763
  startPaginationToken?: string;
707
764
  startTime?: string;
765
+ onProgress?: (progress: MentionsProgress) => void;
708
766
  }) {
709
767
  return Effect.gen(function* () {
710
- const [accountUser] = yield* tryPromise(() =>
711
- lookupUsersByHandles([resolvedAccount.username]),
712
- );
713
- if (!accountUser?.id) {
768
+ const accountUserId =
769
+ resolvedAccount.externalUserId ??
770
+ (yield* tryPromise(() =>
771
+ lookupUsersByHandles([resolvedAccount.username]),
772
+ ).pipe(Effect.map((users) => users[0]?.id)));
773
+ if (!accountUserId) {
714
774
  return yield* Effect.fail(
715
775
  new Error(
716
776
  `Could not resolve Twitter user id for @${resolvedAccount.username}`,
@@ -726,7 +786,7 @@ function fetchMentionsViaXurlEffect({
726
786
  listMentionsViaXurl({
727
787
  maxResults: limit,
728
788
  username: resolvedAccount.username,
729
- userId: String(accountUser.id),
789
+ userId: String(accountUserId),
730
790
  paginationToken: nextToken,
731
791
  ...(sinceId ? { sinceId } : {}),
732
792
  ...(startTime ? { startTime } : {}),
@@ -739,6 +799,22 @@ function fetchMentionsViaXurlEffect({
739
799
  : undefined;
740
800
  nextToken = metaNextToken;
741
801
  pageCount += 1;
802
+ const fetched = pages.reduce((sum, item) => sum + item.data.length, 0);
803
+ const done =
804
+ !all ||
805
+ !nextToken ||
806
+ (parsedMaxPages !== null && pageCount >= parsedMaxPages);
807
+ yield* Effect.sync(() =>
808
+ onProgress?.({
809
+ source: "xurl",
810
+ fetched,
811
+ total: parsedMaxPages === null ? undefined : parsedMaxPages * limit,
812
+ page: pageCount,
813
+ maxPages: parsedMaxPages ?? undefined,
814
+ pageSize: limit,
815
+ done,
816
+ }),
817
+ );
742
818
  } while (
743
819
  all &&
744
820
  nextToken &&
@@ -776,28 +852,34 @@ export function syncMentionsEffect({
776
852
  cacheTtlMs,
777
853
  sinceId,
778
854
  startTime,
855
+ onProgress,
779
856
  }: SyncMentionsOptions) {
780
857
  return Effect.gen(function* () {
781
858
  const parsedMode = yield* trySync(() => parseSyncMode(mode));
859
+ const primaryMode: MentionLiveSource =
860
+ parsedMode === "auto" ? "xurl" : parsedMode;
782
861
  const explicitSinceId = sinceId?.trim() || undefined;
783
862
  const explicitStartTime = startTime?.trim() || undefined;
784
- if (parsedMode === "bird" && (explicitSinceId || explicitStartTime)) {
863
+ if (primaryMode === "bird" && (explicitSinceId || explicitStartTime)) {
785
864
  return yield* Effect.fail(
786
865
  new Error("bird mode does not support --since-id or --start-time"),
787
866
  );
788
867
  }
789
- if (parsedMode === "xurl") {
868
+ if (primaryMode === "xurl") {
790
869
  yield* trySync(() => assertXurlLimit(limit));
791
870
  } else {
792
871
  yield* trySync(() => assertBirdLimit(limit));
793
872
  }
794
873
  const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
795
- const fetchAll = parsedMode === "xurl" && parsedMaxPages !== null;
874
+ const fetchAll =
875
+ primaryMode === "xurl" &&
876
+ (parsedMaxPages !== null ||
877
+ Boolean(explicitSinceId || explicitStartTime));
796
878
  const db = yield* trySync(() => getNativeDb());
797
879
  const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
798
880
  const cursorShape: MentionScanShape = {
799
881
  endpoint: "mentions",
800
- mode: parsedMode,
882
+ mode: primaryMode,
801
883
  accountId: resolvedAccount.accountId,
802
884
  pageSize: limit,
803
885
  boundary: getMentionCursorBoundary({
@@ -808,7 +890,7 @@ export function syncMentionsEffect({
808
890
  const cursorKey = getMentionCursorKey(cursorShape);
809
891
  const legacyCursorKeys = getLegacyMentionCursorKeys(cursorShape);
810
892
  const cursor =
811
- parsedMode === "xurl"
893
+ primaryMode === "xurl"
812
894
  ? yield* trySync(() =>
813
895
  readMentionCursor({
814
896
  db,
@@ -826,15 +908,15 @@ export function syncMentionsEffect({
826
908
  ? cursor.boundary.startTime
827
909
  : undefined;
828
910
  const committedSinceId =
829
- parsedMode === "xurl" &&
911
+ primaryMode === "xurl" &&
830
912
  cursorShape.boundary.kind === "auto" &&
831
913
  !startPaginationToken
832
914
  ? yield* trySync(() =>
833
- readMentionHighWaterId(db, parsedMode, resolvedAccount.accountId),
915
+ readMentionHighWaterId(db, primaryMode, resolvedAccount.accountId),
834
916
  )
835
917
  : undefined;
836
918
  const seededSinceId =
837
- parsedMode === "xurl" &&
919
+ primaryMode === "xurl" &&
838
920
  !explicitSinceId &&
839
921
  !explicitStartTime &&
840
922
  !startPaginationToken
@@ -853,7 +935,7 @@ export function syncMentionsEffect({
853
935
  : undefined;
854
936
  const resultShape: MentionScanShape = {
855
937
  endpoint: "mentions",
856
- mode: parsedMode,
938
+ mode: primaryMode,
857
939
  accountId: resolvedAccount.accountId,
858
940
  pageSize: limit,
859
941
  boundary: getMentionRequestBoundary({
@@ -889,9 +971,17 @@ export function syncMentionsEffect({
889
971
  db,
890
972
  resolvedAccount.accountId,
891
973
  cached.value,
892
- parsedMode,
974
+ primaryMode,
893
975
  ),
894
976
  );
977
+ yield* Effect.sync(() =>
978
+ onProgress?.({
979
+ source: "cache",
980
+ fetched: cached.value.data.length,
981
+ total: parsedMaxPages === null ? undefined : parsedMaxPages * limit,
982
+ done: true,
983
+ }),
984
+ );
895
985
  return {
896
986
  ok: true,
897
987
  source: "cache",
@@ -907,7 +997,7 @@ export function syncMentionsEffect({
907
997
  }
908
998
 
909
999
  if (
910
- parsedMode === "xurl" &&
1000
+ primaryMode === "xurl" &&
911
1001
  !explicitSinceId &&
912
1002
  !explicitStartTime &&
913
1003
  !startPaginationToken &&
@@ -918,8 +1008,15 @@ export function syncMentionsEffect({
918
1008
  );
919
1009
  }
920
1010
 
1011
+ let source: MentionLiveSource = primaryMode;
1012
+ const canFallbackToBird =
1013
+ primaryMode === "xurl" &&
1014
+ parsedMode === "auto" &&
1015
+ !explicitSinceId &&
1016
+ !explicitStartTime &&
1017
+ !startPaginationToken;
921
1018
  const payload =
922
- parsedMode === "bird"
1019
+ primaryMode === "bird"
923
1020
  ? yield* fetchMentionsViaBirdEffect({ limit })
924
1021
  : yield* fetchMentionsViaXurlEffect({
925
1022
  resolvedAccount,
@@ -929,17 +1026,36 @@ export function syncMentionsEffect({
929
1026
  sinceId: resolvedSinceId,
930
1027
  startPaginationToken,
931
1028
  startTime: resolvedStartTime,
932
- });
1029
+ onProgress,
1030
+ }).pipe(
1031
+ Effect.catchAll((error) => {
1032
+ if (!canFallbackToBird) return Effect.fail(error);
1033
+ source = "bird";
1034
+ return verifyBirdAccountMatchesEffect(resolvedAccount).pipe(
1035
+ Effect.flatMap(() => fetchMentionsViaBirdEffect({ limit })),
1036
+ );
1037
+ }),
1038
+ );
1039
+ if (source === "bird") {
1040
+ yield* Effect.sync(() =>
1041
+ onProgress?.({
1042
+ source: "bird",
1043
+ fetched: payload.data.length,
1044
+ total: limit,
1045
+ done: true,
1046
+ }),
1047
+ );
1048
+ }
933
1049
  yield* trySync(() =>
934
1050
  mergeMentionsIntoLocalStore(
935
1051
  db,
936
1052
  resolvedAccount.accountId,
937
1053
  payload,
938
- parsedMode,
1054
+ source,
939
1055
  ),
940
1056
  );
941
1057
  const payloadPaginationToken = getCachedPaginationToken({ value: payload });
942
- if (parsedMode === "xurl") {
1058
+ if (source === "xurl") {
943
1059
  if (payloadPaginationToken) {
944
1060
  yield* trySync(() => {
945
1061
  writeSyncCache(
@@ -976,7 +1092,7 @@ export function syncMentionsEffect({
976
1092
  if (cursorShape.boundary.kind === "auto") {
977
1093
  writeMentionHighWaterId(
978
1094
  db,
979
- parsedMode,
1095
+ source,
980
1096
  resolvedAccount.accountId,
981
1097
  maxNumericTweetId(
982
1098
  resolvedSinceId,
@@ -989,12 +1105,23 @@ export function syncMentionsEffect({
989
1105
  }
990
1106
  }
991
1107
  if (!payloadPaginationToken && !startPaginationToken) {
992
- yield* trySync(() => writeSyncCache(resultCacheKey, payload, db));
1108
+ const writeCacheKey =
1109
+ source === primaryMode
1110
+ ? resultCacheKey
1111
+ : getMentionResultCacheKey({
1112
+ shape: {
1113
+ ...resultShape,
1114
+ mode: source,
1115
+ },
1116
+ all: false,
1117
+ maxPages: null,
1118
+ });
1119
+ yield* trySync(() => writeSyncCache(writeCacheKey, payload, db));
993
1120
  }
994
1121
 
995
1122
  return {
996
1123
  ok: true,
997
- source: parsedMode,
1124
+ source,
998
1125
  kind: "mentions",
999
1126
  accountId: resolvedAccount.accountId,
1000
1127
  count: payload.data.length,
@@ -1020,13 +1147,14 @@ function exportMentionsViaCachedLiveSourceEffect({
1020
1147
  cacheTtlMs,
1021
1148
  }: ExportMentionsViaCachedLiveSourceOptions) {
1022
1149
  return Effect.gen(function* () {
1023
- if (mode === "xurl") {
1150
+ const primaryMode: MentionLiveSource = mode === "auto" ? "xurl" : mode;
1151
+ if (primaryMode === "xurl") {
1024
1152
  yield* trySync(() => assertXurlLimit(limit));
1025
1153
  } else {
1026
1154
  yield* trySync(() => assertBirdLimit(limit));
1027
1155
  }
1028
1156
  const parsedMaxPages = yield* trySync(() => parseMaxPages(maxPages));
1029
- const fetchAll = mode === "xurl" && (all || parsedMaxPages !== null);
1157
+ const fetchAll = primaryMode === "xurl" && (all || parsedMaxPages !== null);
1030
1158
 
1031
1159
  const db = yield* trySync(() => getNativeDb());
1032
1160
  const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
@@ -1068,8 +1196,9 @@ function exportMentionsViaCachedLiveSourceEffect({
1068
1196
  return yield* trySync(() => readFilteredOrRaw(cached.value));
1069
1197
  }
1070
1198
 
1199
+ let source: MentionLiveSource = primaryMode;
1071
1200
  const liveResult = yield* (
1072
- mode === "bird"
1201
+ primaryMode === "bird"
1073
1202
  ? fetchMentionsViaBirdEffect({ limit })
1074
1203
  : fetchMentionsViaXurlEffect({
1075
1204
  resolvedAccount,
@@ -1078,13 +1207,20 @@ function exportMentionsViaCachedLiveSourceEffect({
1078
1207
  parsedMaxPages,
1079
1208
  })
1080
1209
  ).pipe(
1210
+ Effect.catchAll((error) => {
1211
+ if (mode !== "auto" || fetchAll) return Effect.fail(error);
1212
+ source = "bird";
1213
+ return verifyBirdAccountMatchesEffect(resolvedAccount).pipe(
1214
+ Effect.flatMap(() => fetchMentionsViaBirdEffect({ limit })),
1215
+ );
1216
+ }),
1081
1217
  Effect.flatMap((payload) =>
1082
1218
  trySync(() => {
1083
1219
  mergeMentionsIntoLocalStore(
1084
1220
  db,
1085
1221
  resolvedAccount.accountId,
1086
1222
  payload,
1087
- mode,
1223
+ source,
1088
1224
  );
1089
1225
  writeSyncCache(cacheKey, payload, db);
1090
1226
  return readFilteredOrRaw(payload);
@@ -1133,3 +1269,14 @@ export function exportMentionsViaCachedBird(
1133
1269
  }),
1134
1270
  );
1135
1271
  }
1272
+
1273
+ export function exportMentionsViaCachedAuto(
1274
+ options: Omit<ExportMentionsViaCachedLiveSourceOptions, "mode">,
1275
+ ) {
1276
+ return runEffectPromise(
1277
+ exportMentionsViaCachedLiveSourceEffect({
1278
+ ...options,
1279
+ mode: "auto",
1280
+ }),
1281
+ );
1282
+ }