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.
- package/CHANGELOG.md +92 -1
- package/README.md +75 -5
- package/package.json +8 -2
- package/scripts/browser-perf.mjs +1 -0
- package/scripts/start-test-server.mjs +16 -3
- package/src/cli.ts +812 -37
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +37 -7
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/DmWorkspace.tsx +18 -8
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +818 -0
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +60 -25
- package/src/components/ThemeSlider.tsx +55 -50
- package/src/components/TimelineCard.tsx +240 -92
- package/src/components/TimelineRouteFrame.tsx +38 -8
- package/src/components/TweetMediaGrid.tsx +87 -38
- package/src/components/TweetRichText.tsx +45 -17
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +97 -13
- package/src/lib/account-sync-job.ts +666 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +128 -53
- package/src/lib/archive-finder.ts +78 -63
- package/src/lib/archive-import.ts +1593 -1291
- package/src/lib/authored-live.ts +262 -204
- package/src/lib/avatar-cache.ts +208 -43
- package/src/lib/backup.ts +1536 -954
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +582 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/config.ts +35 -2
- package/src/lib/conversation-surface.ts +79 -48
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +95 -4
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/location.ts +137 -0
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +445 -288
- package/src/lib/mentions-live.ts +549 -354
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/network-map.ts +382 -0
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +1317 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +983 -203
- package/src/lib/research.ts +165 -120
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +325 -51
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +75 -3
- package/src/lib/ui.ts +31 -8
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +222 -149
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +1185 -405
- package/src/routeTree.gen.ts +273 -0
- package/src/routes/__root.tsx +24 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +133 -0
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +80 -55
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +75 -29
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/dms.tsx +95 -28
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +455 -0
- package/src/styles.css +22 -0
package/src/lib/avatar-cache.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
252
|
+
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
|
|
253
|
+
if (!normalizedAvatarUrl) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
152
256
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
:
|
|
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,
|