birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -1,11 +1,25 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ import { Effect } from "effect";
4
5
  import { getBirdclawPaths } from "./config";
5
6
  import { getNativeDb } from "./db";
7
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
8
+ import { assertSafePreviewUrl } from "./url-safety";
6
9
 
7
10
  const AVATAR_SIZE_SUFFIX =
8
11
  /(?:(?:_normal|_bigger|_mini))(?=\.(?:jpg|jpeg|png|webp|gif)(?:$|\?))/i;
12
+ const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
13
+ const MAX_AVATAR_DATA_URL_CHARS = MAX_AVATAR_BYTES * 4;
14
+ const REMOTE_AVATAR_TIMEOUT_MS = 10_000;
15
+ const DEFAULT_AVATAR_PREFETCH_CONCURRENCY = 8;
16
+ const ALLOWED_REMOTE_AVATAR_HOSTS = new Set(["pbs.twimg.com"]);
17
+ const RASTER_CONTENT_TYPES = new Set([
18
+ "image/jpeg",
19
+ "image/png",
20
+ "image/webp",
21
+ "image/gif",
22
+ ]);
9
23
 
10
24
  function sanitizeFileToken(value: string) {
11
25
  return value.replace(/[^a-zA-Z0-9_-]+/g, "_");
@@ -23,7 +37,6 @@ function getExtensionFromContentType(contentType: string | null) {
23
37
  if (mime === "image/png") return ".png";
24
38
  if (mime === "image/webp") return ".webp";
25
39
  if (mime === "image/gif") return ".gif";
26
- if (mime === "image/svg+xml") return ".svg";
27
40
  return ".jpg";
28
41
  }
29
42
 
@@ -35,8 +48,6 @@ function getContentTypeFromExtension(extension: string) {
35
48
  return "image/webp";
36
49
  case ".gif":
37
50
  return "image/gif";
38
- case ".svg":
39
- return "image/svg+xml";
40
51
  default:
41
52
  return "image/jpeg";
42
53
  }
@@ -49,7 +60,7 @@ function getExtensionFromAvatarUrl(avatarUrl: string) {
49
60
  if (extension === ".png" || extension === ".webp" || extension === ".gif") {
50
61
  return extension;
51
62
  }
52
- return extension === ".svg" ? ".svg" : ".jpg";
63
+ return ".jpg";
53
64
  } catch {
54
65
  return ".jpg";
55
66
  }
@@ -59,6 +70,9 @@ function decodeDataUrl(dataUrl: string) {
59
70
  if (!dataUrl.startsWith("data:")) {
60
71
  throw new Error("Invalid avatar data URL");
61
72
  }
73
+ if (dataUrl.length > MAX_AVATAR_DATA_URL_CHARS) {
74
+ throw new Error("Avatar data URL is too large");
75
+ }
62
76
 
63
77
  const separatorIndex = dataUrl.indexOf(",");
64
78
  if (separatorIndex < 0) {
@@ -68,12 +82,19 @@ function decodeDataUrl(dataUrl: string) {
68
82
  const metadata = dataUrl.slice(5, separatorIndex);
69
83
  const payload = dataUrl.slice(separatorIndex + 1);
70
84
  const contentType = metadata.split(";")[0] || "application/octet-stream";
85
+ if (!RASTER_CONTENT_TYPES.has(contentType.toLowerCase())) {
86
+ throw new Error("Avatar data URL must be a raster image");
87
+ }
71
88
  const isBase64 = metadata.includes(";base64");
89
+ const buffer = isBase64
90
+ ? Buffer.from(payload, "base64")
91
+ : Buffer.from(decodeURIComponent(payload), "utf8");
92
+ if (buffer.byteLength > MAX_AVATAR_BYTES) {
93
+ throw new Error("Avatar data URL is too large");
94
+ }
72
95
  return {
73
96
  contentType,
74
- buffer: isBase64
75
- ? Buffer.from(payload, "base64")
76
- : Buffer.from(decodeURIComponent(payload), "utf8"),
97
+ buffer,
77
98
  };
78
99
  }
79
100
 
@@ -84,6 +105,70 @@ function getAvatarUrlForProfile(profileId: string) {
84
105
  return row?.avatar_url ?? null;
85
106
  }
86
107
 
108
+ function toError(error: unknown) {
109
+ return error instanceof Error ? error : new Error(String(error));
110
+ }
111
+
112
+ function trySync<T>(try_: () => T) {
113
+ return Effect.try({
114
+ try: try_,
115
+ catch: toError,
116
+ });
117
+ }
118
+
119
+ function assertSafeRemoteAvatarUrl(avatarUrl: string) {
120
+ const parsed = assertSafePreviewUrl(avatarUrl);
121
+ if (parsed.protocol !== "https:") {
122
+ throw new Error("Remote avatar URL must use https");
123
+ }
124
+ if (!ALLOWED_REMOTE_AVATAR_HOSTS.has(parsed.hostname.toLowerCase())) {
125
+ throw new Error("Remote avatar host is not allowed");
126
+ }
127
+ return parsed.toString();
128
+ }
129
+
130
+ function normalizeContentType(value: string | null) {
131
+ return value?.split(";")[0]?.trim().toLowerCase() ?? "image/jpeg";
132
+ }
133
+
134
+ function detectRasterContentType(buffer: Buffer, declared: string) {
135
+ if (
136
+ buffer.length >= 3 &&
137
+ buffer[0] === 0xff &&
138
+ buffer[1] === 0xd8 &&
139
+ buffer[2] === 0xff
140
+ ) {
141
+ return "image/jpeg";
142
+ }
143
+ if (
144
+ buffer.length >= 4 &&
145
+ buffer[0] === 0x89 &&
146
+ buffer[1] === 0x50 &&
147
+ buffer[2] === 0x4e &&
148
+ buffer[3] === 0x47
149
+ ) {
150
+ return "image/png";
151
+ }
152
+ if (
153
+ buffer.length >= 12 &&
154
+ buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
155
+ buffer.subarray(8, 12).toString("ascii") === "WEBP"
156
+ ) {
157
+ return "image/webp";
158
+ }
159
+ if (
160
+ buffer.length >= 6 &&
161
+ (buffer.subarray(0, 6).toString("ascii") === "GIF87a" ||
162
+ buffer.subarray(0, 6).toString("ascii") === "GIF89a")
163
+ ) {
164
+ return "image/gif";
165
+ }
166
+ if (declared === "image/jpeg") {
167
+ return "image/jpeg";
168
+ }
169
+ throw new Error("Avatar response is not a supported raster image");
170
+ }
171
+
87
172
  export function normalizeAvatarUrl(value: unknown) {
88
173
  if (typeof value !== "string" || value.trim().length === 0) {
89
174
  return null;
@@ -122,61 +207,141 @@ export function getAvatarCachePath(profileId: string, avatarUrl: string) {
122
207
  );
123
208
  }
124
209
 
125
- async function fetchRemoteAvatar(avatarUrl: string) {
126
- const response = await fetch(avatarUrl, {
127
- headers: {
128
- "user-agent": "birdclaw/avatar-cache",
129
- },
130
- });
131
- if (!response.ok) {
132
- throw new Error(`Avatar fetch failed with ${response.status}`);
133
- }
210
+ function fetchRemoteAvatarEffect(avatarUrl: string) {
211
+ return Effect.gen(function* () {
212
+ const safeUrl = yield* trySync(() => assertSafeRemoteAvatarUrl(avatarUrl));
213
+ const response = yield* tryPromise(() =>
214
+ fetch(safeUrl, {
215
+ headers: {
216
+ "user-agent": "birdclaw/avatar-cache",
217
+ },
218
+ redirect: "error",
219
+ signal: AbortSignal.timeout(REMOTE_AVATAR_TIMEOUT_MS),
220
+ }),
221
+ );
222
+ if (!response.ok) {
223
+ return yield* Effect.fail(
224
+ new Error(`Avatar fetch failed with ${response.status}`),
225
+ );
226
+ }
134
227
 
135
- const buffer = Buffer.from(await response.arrayBuffer());
136
- return {
137
- contentType: response.headers.get("content-type") ?? "image/jpeg",
138
- buffer,
139
- };
228
+ const buffer = Buffer.from(yield* tryPromise(() => response.arrayBuffer()));
229
+ if (buffer.byteLength > MAX_AVATAR_BYTES) {
230
+ return yield* Effect.fail(new Error("Avatar response is too large"));
231
+ }
232
+ const contentType = yield* trySync(() =>
233
+ detectRasterContentType(
234
+ buffer,
235
+ normalizeContentType(response.headers.get("content-type")),
236
+ ),
237
+ );
238
+ return {
239
+ contentType,
240
+ buffer,
241
+ };
242
+ });
140
243
  }
141
244
 
142
- export async function readCachedAvatar(profileId: string) {
143
- const avatarUrl = getAvatarUrlForProfile(profileId);
144
- if (!avatarUrl) {
145
- return null;
146
- }
245
+ export function readCachedAvatarEffect(profileId: string) {
246
+ return Effect.gen(function* () {
247
+ const avatarUrl = yield* trySync(() => getAvatarUrlForProfile(profileId));
248
+ if (!avatarUrl) {
249
+ return null;
250
+ }
147
251
 
148
- const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
149
- if (!normalizedAvatarUrl) {
150
- return null;
151
- }
252
+ const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
253
+ if (!normalizedAvatarUrl) {
254
+ return null;
255
+ }
152
256
 
153
- const cachePath = getAvatarCachePath(profileId, normalizedAvatarUrl);
154
- const cachedExtension = path.extname(cachePath);
257
+ const cachePath = yield* trySync(() =>
258
+ getAvatarCachePath(profileId, normalizedAvatarUrl),
259
+ );
260
+ const cachedExtension = path.extname(cachePath);
261
+
262
+ const cached = yield* trySync(() => readFileSync(cachePath)).pipe(
263
+ Effect.map((buffer) => ({ ok: true as const, buffer })),
264
+ Effect.catchAll(() => Effect.succeed({ ok: false as const })),
265
+ );
266
+ if (cached.ok) {
267
+ return {
268
+ buffer: cached.buffer,
269
+ contentType: getContentTypeFromExtension(cachedExtension),
270
+ cachePath,
271
+ avatarUrl: normalizedAvatarUrl,
272
+ };
273
+ }
155
274
 
156
- try {
157
- return {
158
- buffer: readFileSync(cachePath),
159
- contentType: getContentTypeFromExtension(cachedExtension),
160
- cachePath,
161
- avatarUrl: normalizedAvatarUrl,
162
- };
163
- } catch {
164
275
  const payload = normalizedAvatarUrl.startsWith("data:")
165
- ? decodeDataUrl(normalizedAvatarUrl)
166
- : await fetchRemoteAvatar(normalizedAvatarUrl);
276
+ ? yield* trySync(() => decodeDataUrl(normalizedAvatarUrl))
277
+ : yield* fetchRemoteAvatarEffect(normalizedAvatarUrl);
167
278
 
168
- writeFileSync(cachePath, payload.buffer);
279
+ yield* trySync(() => writeFileSync(cachePath, payload.buffer));
169
280
  return {
170
281
  buffer: payload.buffer,
171
282
  contentType: payload.contentType,
172
283
  cachePath,
173
284
  avatarUrl: normalizedAvatarUrl,
174
285
  };
286
+ });
287
+ }
288
+
289
+ export function readCachedAvatar(profileId: string) {
290
+ return runEffectPromise(readCachedAvatarEffect(profileId));
291
+ }
292
+
293
+ function normalizePrefetchConcurrency(value: number | undefined) {
294
+ if (!Number.isFinite(value) || value === undefined) {
295
+ return DEFAULT_AVATAR_PREFETCH_CONCURRENCY;
175
296
  }
297
+ return Math.max(1, Math.min(Math.floor(value), 32));
298
+ }
299
+
300
+ export function prefetchCachedAvatarsForProfileIdsEffect(
301
+ profileIds: string[],
302
+ options: { concurrency?: number } = {},
303
+ ) {
304
+ const uniqueProfileIds = Array.from(
305
+ new Set(
306
+ profileIds
307
+ .map((profileId) => profileId.trim())
308
+ .filter((profileId) => profileId.length > 0),
309
+ ),
310
+ );
311
+ const concurrency = normalizePrefetchConcurrency(options.concurrency);
312
+
313
+ return Effect.forEach(
314
+ uniqueProfileIds,
315
+ (profileId) =>
316
+ readCachedAvatarEffect(profileId).pipe(
317
+ Effect.map((avatar) =>
318
+ avatar ? ("available" as const) : ("missing" as const),
319
+ ),
320
+ Effect.catchAll(() => Effect.succeed("failed" as const)),
321
+ ),
322
+ { concurrency },
323
+ ).pipe(
324
+ Effect.map((statuses) => ({
325
+ requested: uniqueProfileIds.length,
326
+ available: statuses.filter((status) => status === "available").length,
327
+ missing: statuses.filter((status) => status === "missing").length,
328
+ failed: statuses.filter((status) => status === "failed").length,
329
+ })),
330
+ );
331
+ }
332
+
333
+ export function prefetchCachedAvatarsForProfileIds(
334
+ profileIds: string[],
335
+ options: { concurrency?: number } = {},
336
+ ) {
337
+ return runEffectPromise(
338
+ prefetchCachedAvatarsForProfileIdsEffect(profileIds, options),
339
+ );
176
340
  }
177
341
 
178
342
  export const __test__ = {
179
343
  decodeDataUrl,
344
+ detectRasterContentType,
180
345
  getAvatarCacheDir,
181
346
  getContentTypeFromExtension,
182
347
  getExtensionFromAvatarUrl,