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
@@ -0,0 +1,296 @@
1
+ import { z } from "zod";
2
+ import { getNativeDb } from "./db";
3
+ import { coordinatesFromLocationKey, normalizeLocationKey } from "./location";
4
+ import type { Database } from "./sqlite";
5
+
6
+ const OpenCageGeometrySchema = z.object({ lat: z.number(), lng: z.number() });
7
+ const OpenCageResultSchema = z.object({
8
+ geometry: OpenCageGeometrySchema,
9
+ confidence: z.number().int().min(0).max(10).optional(),
10
+ formatted: z.string().optional(),
11
+ components: z
12
+ .object({ country_code: z.string().optional() })
13
+ .loose()
14
+ .optional(),
15
+ bounds: z
16
+ .object({
17
+ northeast: OpenCageGeometrySchema.optional(),
18
+ southwest: OpenCageGeometrySchema.optional(),
19
+ })
20
+ .optional(),
21
+ });
22
+ const OpenCageResponseSchema = z.object({
23
+ results: z.array(OpenCageResultSchema),
24
+ status: z.object({ code: z.number(), message: z.string() }).loose(),
25
+ });
26
+
27
+ export interface GeocodeResult {
28
+ normalizedKey: string;
29
+ original: string;
30
+ lat: number;
31
+ lng: number;
32
+ formatted?: string;
33
+ countryCode?: string;
34
+ confidence?: number;
35
+ provider: "opencage" | "coords";
36
+ approxRadiusM?: number;
37
+ bounds?: Record<string, unknown>;
38
+ components?: Record<string, unknown>;
39
+ }
40
+
41
+ export class GeocodeRateLimitError extends Error {
42
+ constructor() {
43
+ super("opencage:429");
44
+ this.name = "GeocodeRateLimitError";
45
+ }
46
+ }
47
+
48
+ const SQLITE_IN_CHUNK_SIZE = 400;
49
+
50
+ export function getOpenCageApiKey() {
51
+ return process.env.OPENCAGE_API_KEY?.trim() || null;
52
+ }
53
+
54
+ function approximateRadiusM(bounds: GeocodeResult["bounds"]) {
55
+ const northeast = bounds?.northeast as
56
+ | { lat?: unknown; lng?: unknown }
57
+ | undefined;
58
+ const southwest = bounds?.southwest as
59
+ | { lat?: unknown; lng?: unknown }
60
+ | undefined;
61
+ if (
62
+ typeof northeast?.lat !== "number" ||
63
+ typeof northeast.lng !== "number" ||
64
+ typeof southwest?.lat !== "number" ||
65
+ typeof southwest.lng !== "number"
66
+ ) {
67
+ return undefined;
68
+ }
69
+ const latMeters = Math.abs(northeast.lat - southwest.lat) * 111_320;
70
+ const lngMeters =
71
+ Math.abs(northeast.lng - southwest.lng) *
72
+ Math.cos((((northeast.lat + southwest.lat) / 2) * Math.PI) / 180) *
73
+ 111_320;
74
+ return Math.max(500, Math.min(120_000, Math.hypot(latMeters, lngMeters) / 2));
75
+ }
76
+
77
+ export function readCachedGeocodes(
78
+ keys: readonly string[],
79
+ db = getNativeDb(),
80
+ ) {
81
+ if (keys.length === 0) return new Map<string, GeocodeResult>();
82
+ const rows: Array<Record<string, unknown>> = [];
83
+ for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
84
+ const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
85
+ const placeholders = chunk.map(() => "?").join(",");
86
+ rows.push(
87
+ ...(db
88
+ .prepare(
89
+ `
90
+ select normalized_key, original, lat, lng, formatted, country_code,
91
+ confidence, provider, approx_radius_m, bounds_json, components_json
92
+ from geocoded_locations
93
+ where normalized_key in (${placeholders})
94
+ `,
95
+ )
96
+ .all(...chunk) as Array<Record<string, unknown>>),
97
+ );
98
+ }
99
+ const map = new Map<string, GeocodeResult>();
100
+ for (const row of rows) {
101
+ const key = String(row.normalized_key);
102
+ map.set(key, {
103
+ normalizedKey: key,
104
+ original: String(row.original),
105
+ lat: Number(row.lat),
106
+ lng: Number(row.lng),
107
+ formatted: typeof row.formatted === "string" ? row.formatted : undefined,
108
+ countryCode:
109
+ typeof row.country_code === "string" ? row.country_code : undefined,
110
+ confidence:
111
+ typeof row.confidence === "number" ? row.confidence : undefined,
112
+ provider: row.provider === "coords" ? "coords" : "opencage",
113
+ approxRadiusM:
114
+ typeof row.approx_radius_m === "number"
115
+ ? row.approx_radius_m
116
+ : undefined,
117
+ bounds: parseJsonRecord(row.bounds_json),
118
+ components: parseJsonRecord(row.components_json),
119
+ });
120
+ }
121
+ return map;
122
+ }
123
+
124
+ export function readSuppressedGeocodeKeys(
125
+ keys: readonly string[],
126
+ db = getNativeDb(),
127
+ ) {
128
+ if (keys.length === 0) return new Set<string>();
129
+ const now = new Date().toISOString();
130
+ const rows: Array<{ normalized_key: string }> = [];
131
+ for (let index = 0; index < keys.length; index += SQLITE_IN_CHUNK_SIZE) {
132
+ const chunk = keys.slice(index, index + SQLITE_IN_CHUNK_SIZE);
133
+ const placeholders = chunk.map(() => "?").join(",");
134
+ rows.push(
135
+ ...(db
136
+ .prepare(
137
+ `
138
+ select normalized_key
139
+ from geocoded_locations_unresolved
140
+ where normalized_key in (${placeholders})
141
+ and (ttl_until is null or ttl_until > ?)
142
+ `,
143
+ )
144
+ .all(...chunk, now) as Array<{ normalized_key: string }>),
145
+ );
146
+ }
147
+ return new Set(rows.map((row) => row.normalized_key));
148
+ }
149
+
150
+ export function storeGeocode(result: GeocodeResult, db = getNativeDb()) {
151
+ const now = new Date().toISOString();
152
+ db.prepare(
153
+ `
154
+ insert into geocoded_locations (
155
+ normalized_key, original, lat, lng, formatted, country_code, confidence,
156
+ provider, approx_radius_m, bounds_json, components_json, hits, created_at,
157
+ last_used_at
158
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
159
+ on conflict(normalized_key) do update set
160
+ original = excluded.original,
161
+ lat = excluded.lat,
162
+ lng = excluded.lng,
163
+ formatted = coalesce(excluded.formatted, geocoded_locations.formatted),
164
+ country_code = coalesce(excluded.country_code, geocoded_locations.country_code),
165
+ confidence = coalesce(excluded.confidence, geocoded_locations.confidence),
166
+ provider = excluded.provider,
167
+ approx_radius_m = coalesce(excluded.approx_radius_m, geocoded_locations.approx_radius_m),
168
+ bounds_json = excluded.bounds_json,
169
+ components_json = excluded.components_json,
170
+ hits = geocoded_locations.hits + 1,
171
+ last_used_at = excluded.last_used_at
172
+ `,
173
+ ).run(
174
+ result.normalizedKey,
175
+ result.original,
176
+ result.lat,
177
+ result.lng,
178
+ result.formatted ?? null,
179
+ result.countryCode?.toUpperCase() ?? null,
180
+ result.confidence ?? null,
181
+ result.provider,
182
+ result.approxRadiusM ?? null,
183
+ JSON.stringify(result.bounds ?? {}),
184
+ JSON.stringify(result.components ?? {}),
185
+ now,
186
+ now,
187
+ );
188
+ db.prepare(
189
+ "delete from geocoded_locations_unresolved where normalized_key = ?",
190
+ ).run(result.normalizedKey);
191
+ }
192
+
193
+ export function storeUnresolvedGeocode(
194
+ normalizedKey: string,
195
+ original: string,
196
+ reason: string,
197
+ db: Database = getNativeDb(),
198
+ ) {
199
+ const now = new Date().toISOString();
200
+ const ttl = new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString();
201
+ db.prepare(
202
+ `
203
+ insert into geocoded_locations_unresolved (
204
+ normalized_key, original, reason, last_attempted_at, ttl_until
205
+ ) values (?, ?, ?, ?, ?)
206
+ on conflict(normalized_key) do update set
207
+ original = excluded.original,
208
+ reason = excluded.reason,
209
+ last_attempted_at = excluded.last_attempted_at,
210
+ ttl_until = excluded.ttl_until
211
+ `,
212
+ ).run(normalizedKey, original, reason, now, ttl);
213
+ }
214
+
215
+ export async function geocodeLocation(
216
+ original: string,
217
+ db: Database = getNativeDb(),
218
+ signal?: AbortSignal,
219
+ ): Promise<GeocodeResult | null> {
220
+ if (signal?.aborted) throw new Error("geocode aborted");
221
+ const normalizedKey = normalizeLocationKey(original);
222
+ if (!normalizedKey) return null;
223
+
224
+ const coord = coordinatesFromLocationKey(normalizedKey);
225
+ if (coord) {
226
+ const result: GeocodeResult = {
227
+ normalizedKey,
228
+ original,
229
+ lat: coord.lat,
230
+ lng: coord.lng,
231
+ formatted: original,
232
+ provider: "coords",
233
+ approxRadiusM: 500,
234
+ };
235
+ storeGeocode(result, db);
236
+ return result;
237
+ }
238
+
239
+ const key = getOpenCageApiKey();
240
+ if (!key) return null;
241
+
242
+ const url = new URL("https://api.opencagedata.com/geocode/v1/json");
243
+ url.searchParams.set("q", original);
244
+ url.searchParams.set("key", key);
245
+ url.searchParams.set("limit", "1");
246
+ url.searchParams.set("no_annotations", "1");
247
+
248
+ const response = await fetch(url, { signal });
249
+ if (!response.ok) {
250
+ if (response.status === 429) {
251
+ throw new GeocodeRateLimitError();
252
+ }
253
+ storeUnresolvedGeocode(
254
+ normalizedKey,
255
+ original,
256
+ `opencage:${String(response.status)}`,
257
+ db,
258
+ );
259
+ return null;
260
+ }
261
+ const payload = OpenCageResponseSchema.parse(await response.json());
262
+ const first = payload.results[0];
263
+ if (!first) {
264
+ storeUnresolvedGeocode(normalizedKey, original, "no-result", db);
265
+ return null;
266
+ }
267
+
268
+ const bounds = first.bounds as Record<string, unknown> | undefined;
269
+ const result: GeocodeResult = {
270
+ normalizedKey,
271
+ original,
272
+ lat: first.geometry.lat,
273
+ lng: first.geometry.lng,
274
+ formatted: first.formatted,
275
+ countryCode: first.components?.country_code?.toUpperCase(),
276
+ confidence: first.confidence,
277
+ provider: "opencage",
278
+ approxRadiusM: approximateRadiusM(bounds),
279
+ bounds,
280
+ components: first.components as Record<string, unknown> | undefined,
281
+ };
282
+ storeGeocode(result, db);
283
+ return result;
284
+ }
285
+
286
+ function parseJsonRecord(value: unknown) {
287
+ if (typeof value !== "string" || value.length === 0) return undefined;
288
+ try {
289
+ const parsed = JSON.parse(value) as unknown;
290
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
291
+ ? (parsed as Record<string, unknown>)
292
+ : undefined;
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
@@ -0,0 +1,222 @@
1
+ import { Effect } from "effect";
2
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
+
4
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
5
+
6
+ export function jsonResponse(data: unknown, init?: ResponseInit) {
7
+ const headers = new Headers(init?.headers);
8
+ headers.set("content-type", "application/json");
9
+ return new Response(JSON.stringify(data), {
10
+ ...init,
11
+ headers,
12
+ });
13
+ }
14
+
15
+ export function requestJsonEffect<T = Record<string, unknown>>(
16
+ request: Request,
17
+ fallback?: T,
18
+ ): Effect.Effect<T, unknown> {
19
+ return tryPromise(() => request.json() as Promise<T>).pipe(
20
+ Effect.catchAll((error) =>
21
+ fallback === undefined ? Effect.fail(error) : Effect.succeed(fallback),
22
+ ),
23
+ );
24
+ }
25
+
26
+ export function runRouteEffect<A, E>(effect: Effect.Effect<A, E>): Promise<A> {
27
+ return runEffectPromise(effect);
28
+ }
29
+
30
+ function normalizedHost(value: string) {
31
+ return value.toLowerCase().replace(/^\[|\]$/g, "");
32
+ }
33
+
34
+ function isTestEnvironment() {
35
+ return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
36
+ }
37
+
38
+ function requestCookie(request: Request, name: string) {
39
+ const cookie = request.headers.get("cookie");
40
+ if (!cookie) return { found: false, value: null };
41
+ for (const part of cookie.split(";")) {
42
+ const [key, ...rest] = part.trim().split("=");
43
+ if (key === name) {
44
+ try {
45
+ return { found: true, value: decodeURIComponent(rest.join("=")) };
46
+ } catch {
47
+ return { found: true, value: null };
48
+ }
49
+ }
50
+ }
51
+ return { found: false, value: null };
52
+ }
53
+
54
+ function configuredWebToken() {
55
+ const token = process.env.BIRDCLAW_WEB_TOKEN?.trim();
56
+ return token || null;
57
+ }
58
+
59
+ function requestWebTokenStatus(request: Request) {
60
+ const token = configuredWebToken();
61
+ if (!token)
62
+ return {
63
+ configured: false,
64
+ valid: false,
65
+ fromCookie: false,
66
+ provided: false,
67
+ };
68
+ const headerToken = request.headers.get("x-birdclaw-token");
69
+ const cookieToken = requestCookie(request, "birdclaw_token");
70
+ const provided = headerToken !== null || cookieToken.found;
71
+ const cookieValue = cookieToken.value;
72
+ const valid = headerToken === token || cookieValue === token;
73
+ return {
74
+ configured: true,
75
+ valid,
76
+ fromCookie: cookieValue === token && headerToken !== token,
77
+ provided,
78
+ };
79
+ }
80
+
81
+ function isLocalWebHost(value: string) {
82
+ const host = normalizedHost(value);
83
+ return LOCAL_HOSTS.has(host) || host.endsWith(".localhost");
84
+ }
85
+
86
+ function allowsUnauthenticatedLocalWeb() {
87
+ return process.env.BIRDCLAW_LOCAL_WEB === "1";
88
+ }
89
+
90
+ function hasForwardedRequestHeaders(request: Request) {
91
+ return (
92
+ request.headers.has("forwarded") ||
93
+ request.headers.has("x-forwarded-for") ||
94
+ request.headers.has("x-forwarded-proto") ||
95
+ request.headers.has("x-forwarded-host") ||
96
+ request.headers.has("x-real-ip")
97
+ );
98
+ }
99
+
100
+ function firstForwardedValue(value: string | null) {
101
+ return value?.split(",")[0]?.trim() || null;
102
+ }
103
+
104
+ function forwardedHeaderPair(request: Request, key: "host" | "proto") {
105
+ const forwarded = firstForwardedValue(request.headers.get("forwarded"));
106
+ if (!forwarded) return null;
107
+ for (const part of forwarded.split(";")) {
108
+ const [name, ...rest] = part.trim().split("=");
109
+ if (name?.toLowerCase() !== key) continue;
110
+ const value = rest.join("=").trim().replace(/^"|"$/g, "");
111
+ return value || null;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function forwardedOrigin(request: Request) {
117
+ const proto =
118
+ firstForwardedValue(request.headers.get("x-forwarded-proto")) ??
119
+ forwardedHeaderPair(request, "proto");
120
+ const host =
121
+ firstForwardedValue(request.headers.get("x-forwarded-host")) ??
122
+ forwardedHeaderPair(request, "host");
123
+ if (!proto || !host) return null;
124
+ const normalizedProto = proto.toLowerCase();
125
+ if (normalizedProto !== "http" && normalizedProto !== "https") return null;
126
+ return `${normalizedProto}://${host}`;
127
+ }
128
+
129
+ function sameRequestOrigin(request: Request, origin: string, url: URL) {
130
+ return origin === url.origin || origin === forwardedOrigin(request);
131
+ }
132
+
133
+ export function sensitiveRequestErrorResponse(request: Request) {
134
+ const url = new URL(request.url);
135
+ const token = requestWebTokenStatus(request);
136
+ const isLocalRequest =
137
+ allowsUnauthenticatedLocalWeb() &&
138
+ isLocalWebHost(url.hostname) &&
139
+ !hasForwardedRequestHeaders(request);
140
+ const fetchSite = request.headers.get("sec-fetch-site");
141
+ const origin = request.headers.get("origin");
142
+ const allowRemoteEnv = process.env.BIRDCLAW_ALLOW_REMOTE_WEB === "1";
143
+ const allowTrustedRemote = allowRemoteEnv && !token.configured;
144
+
145
+ if (isTestEnvironment() && !token.valid) return null;
146
+
147
+ if (origin && !sameRequestOrigin(request, origin, url)) {
148
+ return jsonResponse(
149
+ { ok: false, message: "Cross-origin web API access is disabled" },
150
+ { status: 403 },
151
+ );
152
+ }
153
+
154
+ if (fetchSite === "cross-site") {
155
+ return jsonResponse(
156
+ { ok: false, message: "Cross-site web API access is disabled" },
157
+ { status: 403 },
158
+ );
159
+ }
160
+
161
+ if (
162
+ !isTestEnvironment() &&
163
+ !token.configured &&
164
+ !isLocalRequest &&
165
+ !allowTrustedRemote
166
+ ) {
167
+ return jsonResponse(
168
+ {
169
+ ok: false,
170
+ message:
171
+ "Remote API access requires BIRDCLAW_ALLOW_REMOTE_WEB=1 for a trusted private proxy, or BIRDCLAW_WEB_TOKEN for tokened access",
172
+ },
173
+ { status: 403 },
174
+ );
175
+ }
176
+
177
+ if (token.configured && !token.valid && (token.provided || !isLocalRequest)) {
178
+ return jsonResponse(
179
+ { ok: false, message: "Invalid web token" },
180
+ { status: 403 },
181
+ );
182
+ }
183
+
184
+ const allowRemote = allowRemoteEnv && (token.valid || allowTrustedRemote);
185
+ if (!allowRemote && !isLocalRequest) {
186
+ return jsonResponse(
187
+ { ok: false, message: "Remote web API access is disabled" },
188
+ { status: 403 },
189
+ );
190
+ }
191
+
192
+ if (
193
+ token.fromCookie &&
194
+ fetchSite !== "same-origin" &&
195
+ fetchSite !== "same-site"
196
+ ) {
197
+ return jsonResponse(
198
+ { ok: false, message: "Same-origin browser request required" },
199
+ { status: 403 },
200
+ );
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ export function parseBoundedInteger(
207
+ value: number | string | null | undefined,
208
+ {
209
+ defaultValue,
210
+ min = 1,
211
+ max,
212
+ }: { defaultValue?: number; min?: number; max: number },
213
+ ) {
214
+ if (value === null || value === undefined || value === "")
215
+ return defaultValue;
216
+ if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
217
+ return defaultValue;
218
+ }
219
+ const parsed = typeof value === "number" ? value : Number(value);
220
+ if (!Number.isSafeInteger(parsed)) return defaultValue;
221
+ return Math.min(max, Math.max(min, parsed));
222
+ }
package/src/lib/inbox.ts CHANGED
@@ -1,5 +1,7 @@
1
+ import { Effect } from "effect";
1
2
  import { getNativeDb } from "./db";
2
- import { scoreInboxItemWithOpenAI } from "./openai";
3
+ import { runEffectPromise } from "./effect-runtime";
4
+ import { scoreInboxItemWithOpenAIEffect } from "./openai";
3
5
  import { listDmConversations, listTimelineItems } from "./queries";
4
6
  import type { InboxItem, InboxQuery, InboxResponse } from "./types";
5
7
 
@@ -61,6 +63,7 @@ function readStoredScores() {
61
63
 
62
64
  export function listInboxItems({
63
65
  kind = "mixed",
66
+ account,
64
67
  minScore = 0,
65
68
  hideLowSignal = false,
66
69
  limit = 20,
@@ -71,6 +74,7 @@ export function listInboxItems({
71
74
  if (kind === "mixed" || kind === "mentions") {
72
75
  for (const mention of listTimelineItems({
73
76
  resource: "mentions",
77
+ account,
74
78
  replyFilter: "unreplied",
75
79
  limit: 50,
76
80
  })) {
@@ -102,8 +106,9 @@ export function listInboxItems({
102
106
 
103
107
  if (kind === "mixed" || kind === "dms") {
104
108
  for (const dm of listDmConversations({
109
+ account,
105
110
  replyFilter: "unreplied",
106
- sort: "influence",
111
+ sort: "followers",
107
112
  limit: 50,
108
113
  })) {
109
114
  const scoreKey = `dm:${dm.id}`;
@@ -151,30 +156,47 @@ export function listInboxItems({
151
156
  };
152
157
  }
153
158
 
154
- export async function scoreInbox({
159
+ function toError(error: unknown) {
160
+ return error instanceof Error ? error : new Error(String(error));
161
+ }
162
+
163
+ function trySync<T>(try_: () => T) {
164
+ return Effect.try({
165
+ try: try_,
166
+ catch: toError,
167
+ });
168
+ }
169
+
170
+ export function scoreInboxEffect({
155
171
  kind = "mixed",
172
+ account,
156
173
  limit = 8,
157
- }: Pick<InboxQuery, "kind" | "limit"> = {}) {
158
- const db = getNativeDb();
159
- const items = listInboxItems({ kind, limit }).items;
160
- const results = [];
161
-
162
- for (const item of items) {
163
- const scored = await scoreInboxItemWithOpenAI({
164
- entityKind: item.entityKind,
165
- title: item.title,
166
- text: item.text,
167
- influenceScore: item.influenceScore,
168
- participant: {
169
- handle: item.participant.handle,
170
- displayName: item.participant.displayName,
171
- bio: item.participant.bio,
172
- followersCount: item.participant.followersCount,
173
- },
174
- });
174
+ }: Pick<InboxQuery, "kind" | "account" | "limit"> = {}) {
175
+ return Effect.gen(function* () {
176
+ const db = yield* trySync(() => getNativeDb());
177
+ const items = yield* trySync(
178
+ () => listInboxItems({ kind, account, limit }).items,
179
+ );
180
+ const results = [];
175
181
 
176
- db.prepare(
177
- `
182
+ for (const item of items) {
183
+ const scored = yield* scoreInboxItemWithOpenAIEffect({
184
+ entityKind: item.entityKind,
185
+ title: item.title,
186
+ text: item.text,
187
+ influenceScore: item.influenceScore,
188
+ participant: {
189
+ handle: item.participant.handle,
190
+ displayName: item.participant.displayName,
191
+ bio: item.participant.bio,
192
+ followersCount: item.participant.followersCount,
193
+ },
194
+ });
195
+
196
+ yield* trySync(() =>
197
+ db
198
+ .prepare(
199
+ `
178
200
  insert into ai_scores (
179
201
  entity_kind, entity_id, model, score, summary, reasoning, updated_at
180
202
  ) values (?, ?, ?, ?, ?, ?, ?)
@@ -185,26 +207,35 @@ export async function scoreInbox({
185
207
  reasoning = excluded.reasoning,
186
208
  updated_at = excluded.updated_at
187
209
  `,
188
- ).run(
189
- item.entityKind,
190
- item.entityId,
191
- scored.model,
192
- scored.score,
193
- scored.summary,
194
- scored.reasoning,
195
- new Date().toISOString(),
196
- );
210
+ )
211
+ .run(
212
+ item.entityKind,
213
+ item.entityId,
214
+ scored.model,
215
+ scored.score,
216
+ scored.summary,
217
+ scored.reasoning,
218
+ new Date().toISOString(),
219
+ ),
220
+ );
197
221
 
198
- results.push({
199
- id: item.id,
200
- score: scored.score,
201
- source: "openai",
202
- });
203
- }
222
+ results.push({
223
+ id: item.id,
224
+ score: scored.score,
225
+ source: "openai",
226
+ });
227
+ }
204
228
 
205
- return {
206
- ok: true,
207
- scored: results.length,
208
- items: results,
209
- };
229
+ return {
230
+ ok: true,
231
+ scored: results.length,
232
+ items: results,
233
+ };
234
+ });
235
+ }
236
+
237
+ export function scoreInbox(
238
+ options: Pick<InboxQuery, "kind" | "account" | "limit"> = {},
239
+ ) {
240
+ return runEffectPromise(scoreInboxEffect(options));
210
241
  }