birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -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
  }
@@ -1,4 +1,6 @@
1
+ import { Effect } from "effect";
1
2
  import { getNativeDb } from "./db";
3
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
2
4
  import type { Database } from "./sqlite";
3
5
  import {
4
6
  normalizeUrlExpansionForIndex,
@@ -246,11 +248,19 @@ function rebuildOccurrences(
246
248
  return { occurrences, entityExpansions };
247
249
  }
248
250
 
249
- async function expandWithConcurrency(
251
+ function expandWithConcurrencyEffect(
250
252
  db: Database,
251
253
  urls: string[],
252
254
  options: LinkBackfillOptions,
253
- ) {
255
+ ): Effect.Effect<
256
+ {
257
+ networkExpansions: number;
258
+ cacheExpansions: number;
259
+ misses: number;
260
+ errors: number;
261
+ },
262
+ unknown
263
+ > {
254
264
  const concurrency = Math.max(
255
265
  1,
256
266
  Math.min(options.concurrency ?? DEFAULT_EXPAND_CONCURRENCY, 64),
@@ -261,62 +271,57 @@ async function expandWithConcurrency(
261
271
  misses: 0,
262
272
  errors: 0,
263
273
  };
264
- let nextIndex = 0;
265
274
 
266
- async function worker() {
267
- for (;;) {
268
- const index = nextIndex++;
269
- const url = urls[index];
270
- if (!url) {
271
- return;
272
- }
273
- const result = (
274
- await expandUrls([url], {
275
+ return Effect.forEach(
276
+ urls,
277
+ (url) =>
278
+ tryPromise(() =>
279
+ expandUrls([url], {
275
280
  refresh: options.refresh,
276
281
  fetchImpl: options.fetchImpl,
277
282
  timeoutMs: options.timeoutMs,
278
- })
279
- )[0]!;
280
- if (result.source === "network") {
281
- counts.networkExpansions++;
282
- } else {
283
- counts.cacheExpansions++;
284
- }
285
- if (result.status === "miss") {
286
- counts.misses++;
287
- }
288
- if (result.status === "error") {
289
- counts.errors++;
290
- }
291
- upsertUrlExpansion(db, normalizeUrlExpansionForIndex(result));
292
- }
293
- }
294
-
295
- await Promise.all(
296
- Array.from({ length: Math.min(concurrency, urls.length) }, () => worker()),
297
- );
298
- return counts;
283
+ }),
284
+ ).pipe(
285
+ Effect.map((results) => {
286
+ const result = results[0]!;
287
+ if (result.source === "network") {
288
+ counts.networkExpansions++;
289
+ } else {
290
+ counts.cacheExpansions++;
291
+ }
292
+ if (result.status === "miss") {
293
+ counts.misses++;
294
+ }
295
+ if (result.status === "error") {
296
+ counts.errors++;
297
+ }
298
+ upsertUrlExpansion(db, normalizeUrlExpansionForIndex(result));
299
+ }),
300
+ ),
301
+ { concurrency },
302
+ ).pipe(Effect.as(counts));
299
303
  }
300
304
 
301
- export async function backfillLinkIndex(
305
+ export function backfillLinkIndexEffect(
302
306
  options: LinkBackfillOptions = {},
303
- ): Promise<LinkBackfillResult> {
304
- const db = getNativeDb({ seedDemoData: false });
305
- const { occurrences, entityExpansions } = rebuildOccurrences(
306
- db,
307
- Boolean(options.includeAllUrls),
308
- options.source,
309
- );
310
- const limit =
311
- typeof options.limit === "number" && Number.isFinite(options.limit)
312
- ? Math.max(0, Math.trunc(options.limit))
313
- : undefined;
314
- const needsExpansionClause = options.refresh
315
- ? "1 = 1"
316
- : "(e.short_url is null or e.status in ('error', 'miss'))";
317
-
318
- const urlsToExpand = db
319
- .prepare(`
307
+ ): Effect.Effect<LinkBackfillResult, unknown> {
308
+ return Effect.gen(function* () {
309
+ const db = getNativeDb({ seedDemoData: false });
310
+ const { occurrences, entityExpansions } = rebuildOccurrences(
311
+ db,
312
+ Boolean(options.includeAllUrls),
313
+ options.source,
314
+ );
315
+ const limit =
316
+ typeof options.limit === "number" && Number.isFinite(options.limit)
317
+ ? Math.max(0, Math.trunc(options.limit))
318
+ : undefined;
319
+ const needsExpansionClause = options.refresh
320
+ ? "1 = 1"
321
+ : "(e.short_url is null or e.status in ('error', 'miss'))";
322
+
323
+ const urlsToExpand = db
324
+ .prepare(`
320
325
  select distinct o.short_url
321
326
  from link_occurrences o
322
327
  left join url_expansions e on e.short_url = o.short_url
@@ -325,43 +330,50 @@ export async function backfillLinkIndex(
325
330
  order by o.short_url
326
331
  ${limit === undefined ? "" : "limit ?"}
327
332
  `)
328
- .all(
329
- ...(options.source ? [options.source] : []),
330
- ...(limit === undefined ? [] : [limit]),
331
- ) as Array<{
332
- short_url: string;
333
- }>;
334
-
335
- const expansionCounts = await expandWithConcurrency(
336
- db,
337
- urlsToExpand.map((row) => row.short_url),
338
- options,
339
- );
333
+ .all(
334
+ ...(options.source ? [options.source] : []),
335
+ ...(limit === undefined ? [] : [limit]),
336
+ ) as Array<{
337
+ short_url: string;
338
+ }>;
339
+
340
+ const expansionCounts = yield* expandWithConcurrencyEffect(
341
+ db,
342
+ urlsToExpand.map((row) => row.short_url),
343
+ options,
344
+ );
340
345
 
341
- const uniqueUrls = db
342
- .prepare(`
346
+ const uniqueUrls = db
347
+ .prepare(`
343
348
  select count(distinct short_url) as count
344
349
  from link_occurrences
345
350
  ${options.source ? "where source_kind = ?" : ""}
346
351
  `)
347
- .get(...(options.source ? [options.source] : [])) as { count: number };
348
- const remaining = db
349
- .prepare(`
352
+ .get(...(options.source ? [options.source] : [])) as { count: number };
353
+ const remaining = db
354
+ .prepare(`
350
355
  select count(distinct o.short_url) as count
351
356
  from link_occurrences o
352
357
  left join url_expansions e on e.short_url = o.short_url
353
358
  where (e.short_url is null or e.status in ('error', 'miss'))
354
359
  ${options.source ? "and o.source_kind = ?" : ""}
355
360
  `)
356
- .get(...(options.source ? [options.source] : [])) as { count: number };
361
+ .get(...(options.source ? [options.source] : [])) as { count: number };
362
+
363
+ return {
364
+ occurrences,
365
+ uniqueUrls: Number(uniqueUrls.count),
366
+ entityExpansions,
367
+ ...expansionCounts,
368
+ remainingUnexpanded: Number(remaining.count),
369
+ };
370
+ });
371
+ }
357
372
 
358
- return {
359
- occurrences,
360
- uniqueUrls: Number(uniqueUrls.count),
361
- entityExpansions,
362
- ...expansionCounts,
363
- remainingUnexpanded: Number(remaining.count),
364
- };
373
+ export function backfillLinkIndex(
374
+ options: LinkBackfillOptions = {},
375
+ ): Promise<LinkBackfillResult> {
376
+ return runEffectPromise(backfillLinkIndexEffect(options));
365
377
  }
366
378
 
367
379
  function likePattern(value: string) {
@@ -635,6 +635,30 @@ export function getLinkInsights(
635
635
  conditions.push("o.source_kind = ?");
636
636
  params.push(source);
637
637
  }
638
+ if (query.account && query.account !== "all") {
639
+ conditions.push(`(
640
+ (o.source_kind = 'dm' and o.account_id = ?)
641
+ or (
642
+ o.source_kind = 'tweet'
643
+ and (
644
+ o.account_id = ?
645
+ or exists (
646
+ select 1
647
+ from tweet_account_edges edge
648
+ where edge.account_id = ?
649
+ and edge.tweet_id = o.source_id
650
+ )
651
+ or exists (
652
+ select 1
653
+ from tweet_collections collection
654
+ where collection.account_id = ?
655
+ and collection.tweet_id = o.source_id
656
+ )
657
+ )
658
+ )
659
+ )`);
660
+ params.push(query.account, query.account, query.account, query.account);
661
+ }
638
662
  if (kind === "videos") {
639
663
  addVideoUrlPrefilter(conditions);
640
664
  }