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
@@ -1,16 +1,43 @@
1
+ import { Effect } from "effect";
1
2
  import type { Database } from "./sqlite";
2
3
  import {
3
4
  type BirdDmConversation,
4
5
  type BirdDmEvent,
5
6
  type BirdDmUser,
6
- listDirectMessagesViaBird,
7
+ getAuthenticatedBirdAccountEffect,
8
+ listDirectMessagesViaBirdEffect,
7
9
  } from "./bird";
8
10
  import { getNativeDb } from "./db";
11
+ import { runEffectPromise } from "./effect-runtime";
9
12
  import { readSyncCache, writeSyncCache } from "./sync-cache";
10
- import type { XurlMentionUser } from "./types";
11
- import { upsertProfileFromXUser } from "./x-profile";
13
+ import type { XurlDmEventsResponse, XurlMentionUser } from "./types";
14
+ import {
15
+ listDirectMessageEventsViaXurlEffect,
16
+ lookupAuthenticatedOAuth2UserEffect,
17
+ } from "./xurl";
18
+ import {
19
+ buildExternalProfileId,
20
+ randomAvatarHue,
21
+ upsertProfileFromXUser,
22
+ } from "./x-profile";
12
23
 
13
24
  export const DEFAULT_DMS_CACHE_TTL_MS = 2 * 60_000;
25
+ const PREVIEW_MESSAGE_ID_PREFIX = "preview:";
26
+ const XURL_DMS_MAX_RESULTS = 100;
27
+
28
+ export type DirectMessagesSyncMode = "auto" | "bird" | "xurl";
29
+
30
+ export interface SyncDirectMessagesViaCachedBirdOptions {
31
+ account?: string;
32
+ mode?: DirectMessagesSyncMode;
33
+ limit?: number;
34
+ inbox?: "all" | "accepted" | "requests";
35
+ maxPages?: number;
36
+ allPages?: boolean;
37
+ pageDelayMs?: number;
38
+ refresh?: boolean;
39
+ cacheTtlMs?: number;
40
+ }
14
41
 
15
42
  function parseCacheTtlMs(value?: number) {
16
43
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
@@ -25,21 +52,60 @@ function assertBirdLimit(limit: number) {
25
52
  }
26
53
  }
27
54
 
55
+ function assertXurlLimit(limit: number) {
56
+ if (!Number.isFinite(limit) || limit < 1 || limit > XURL_DMS_MAX_RESULTS) {
57
+ throw new Error("xurl DM mode requires --limit between 1 and 100");
58
+ }
59
+ }
60
+
61
+ function parseSyncMode(mode: DirectMessagesSyncMode | undefined) {
62
+ if (!mode || mode === "bird" || mode === "xurl" || mode === "auto") {
63
+ return mode ?? "bird";
64
+ }
65
+ throw new Error("--mode must be auto, bird, or xurl");
66
+ }
67
+
68
+ function normalizeExternalUserId(value: string | null | undefined) {
69
+ const trimmed = value?.trim();
70
+ return trimmed ? trimmed : undefined;
71
+ }
72
+
73
+ function makePreviewMessageId(conversationId: string): string {
74
+ return `${PREVIEW_MESSAGE_ID_PREFIX}${conversationId}`;
75
+ }
76
+
77
+ function deleteDmFtsRows(db: Database, messageIds: string[]) {
78
+ const chunkSize = 500;
79
+ for (let index = 0; index < messageIds.length; index += chunkSize) {
80
+ const chunk = messageIds.slice(index, index + chunkSize);
81
+ if (chunk.length === 0) continue;
82
+ db.prepare(
83
+ `delete from dm_fts where message_id in (${chunk.map(() => "?").join(",")})`,
84
+ ).run(...chunk);
85
+ }
86
+ }
87
+
28
88
  function resolveAccount(db: Database, accountId?: string) {
29
89
  const row = accountId
30
90
  ? (db
31
- .prepare("select id, handle from accounts where id = ?")
32
- .get(accountId) as { id: string; handle: string } | undefined)
91
+ .prepare(
92
+ "select id, handle, external_user_id from accounts where id = ?",
93
+ )
94
+ .get(accountId) as
95
+ | { id: string; handle: string; external_user_id: string | null }
96
+ | undefined)
33
97
  : (db
34
98
  .prepare(
35
99
  `
36
- select id, handle
100
+ select id, handle, external_user_id
37
101
  from accounts
38
102
  order by is_default desc, created_at asc
39
103
  limit 1
40
104
  `,
41
105
  )
42
- .get() as { id: string; handle: string } | undefined);
106
+ .get() as
107
+ | { id: string; handle: string; external_user_id: string | null }
108
+ | undefined);
43
109
 
44
110
  if (!row) {
45
111
  throw new Error(`Unknown account: ${accountId ?? "default"}`);
@@ -48,6 +114,7 @@ function resolveAccount(db: Database, accountId?: string) {
48
114
  return {
49
115
  accountId: row.id,
50
116
  username: row.handle.replace(/^@/, ""),
117
+ externalUserId: normalizeExternalUserId(row.external_user_id),
51
118
  };
52
119
  }
53
120
 
@@ -69,15 +136,30 @@ function toXUser(user: BirdDmUser): XurlMentionUser {
69
136
  };
70
137
  }
71
138
 
72
- function collectUsers(payload: {
73
- conversations: BirdDmConversation[];
74
- events: BirdDmEvent[];
75
- }) {
139
+ function collectUsers(
140
+ payload: {
141
+ conversations: BirdDmConversation[];
142
+ events: BirdDmEvent[];
143
+ },
144
+ accountExternalUserId?: string,
145
+ ) {
76
146
  const users = new Map<string, BirdDmUser>();
77
147
  const add = (user?: BirdDmUser) => {
78
148
  if (!user?.id) return;
149
+ if (
150
+ accountExternalUserId &&
151
+ user.id === accountExternalUserId &&
152
+ !user.username &&
153
+ !user.name
154
+ ) {
155
+ return;
156
+ }
79
157
  users.set(user.id, { ...users.get(user.id), ...user });
80
158
  };
159
+ const addId = (id?: string) => {
160
+ if (!id || users.has(id) || id === accountExternalUserId) return;
161
+ users.set(id, { id });
162
+ };
81
163
 
82
164
  for (const conversation of payload.conversations) {
83
165
  for (const participant of conversation.participants) {
@@ -87,6 +169,8 @@ function collectUsers(payload: {
87
169
  for (const event of payload.events) {
88
170
  add(event.sender);
89
171
  add(event.recipient);
172
+ addId(event.senderId);
173
+ addId(event.recipientId);
90
174
  }
91
175
  return users;
92
176
  }
@@ -94,9 +178,14 @@ function collectUsers(payload: {
94
178
  function getLocalExternalUserId(
95
179
  users: Map<string, BirdDmUser>,
96
180
  accountUsername: string,
181
+ accountExternalUserId?: string,
97
182
  ) {
183
+ if (accountExternalUserId) {
184
+ return accountExternalUserId;
185
+ }
186
+ const normalizedAccountUsername = accountUsername.toLowerCase();
98
187
  for (const user of users.values()) {
99
- if (user.username === accountUsername) {
188
+ if (user.username?.toLowerCase() === normalizedAccountUsername) {
100
189
  return user.id;
101
190
  }
102
191
  }
@@ -111,22 +200,199 @@ function getLatestEvent(events: BirdDmEvent[]) {
111
200
  )[0];
112
201
  }
113
202
 
203
+ function assertAuthenticatedBirdAccountMatches({
204
+ source,
205
+ accountId,
206
+ username,
207
+ externalUserId,
208
+ liveUsername,
209
+ liveExternalUserId,
210
+ }: {
211
+ source: "bird" | "xurl";
212
+ accountId: string;
213
+ username: string;
214
+ externalUserId?: string;
215
+ liveUsername: string;
216
+ liveExternalUserId?: string;
217
+ }) {
218
+ if (
219
+ externalUserId &&
220
+ liveExternalUserId &&
221
+ liveExternalUserId === externalUserId
222
+ ) {
223
+ return;
224
+ }
225
+ if (externalUserId && liveExternalUserId) {
226
+ throw new Error(
227
+ `${source} is authenticated as user ${liveExternalUserId}; refusing to sync into ${accountId} (${externalUserId})`,
228
+ );
229
+ }
230
+ if (liveUsername.toLowerCase() !== username.toLowerCase()) {
231
+ throw new Error(
232
+ `${source} is authenticated as @${liveUsername}; refusing to sync into ${accountId} (@${username})`,
233
+ );
234
+ }
235
+ }
236
+
237
+ function getAuthenticatedXurlAccount(payload: Record<string, unknown> | null): {
238
+ id?: string;
239
+ username?: string;
240
+ } {
241
+ if (!payload) return {};
242
+ return {
243
+ ...(typeof payload.id === "string" ? { id: payload.id } : {}),
244
+ ...(typeof payload.username === "string"
245
+ ? { username: payload.username }
246
+ : {}),
247
+ };
248
+ }
249
+
250
+ function persistAccountExternalUserId(
251
+ db: Database,
252
+ accountId: string,
253
+ externalUserId: string,
254
+ ) {
255
+ db.prepare(
256
+ `
257
+ update accounts
258
+ set external_user_id = ?
259
+ where id = ?
260
+ and (external_user_id is null or trim(external_user_id) = '')
261
+ `,
262
+ ).run(externalUserId, accountId);
263
+ }
264
+
265
+ function conversationIdReferencesExternalUserId(
266
+ conversationId: string,
267
+ externalUserId: string,
268
+ ) {
269
+ return conversationId.split(/[^0-9]+/).includes(externalUserId);
270
+ }
271
+
272
+ function payloadReferencesExternalUserId(
273
+ payload: {
274
+ conversations: BirdDmConversation[];
275
+ events: BirdDmEvent[];
276
+ },
277
+ externalUserId: string,
278
+ ) {
279
+ for (const conversation of payload.conversations) {
280
+ if (
281
+ conversationIdReferencesExternalUserId(conversation.id, externalUserId)
282
+ ) {
283
+ return true;
284
+ }
285
+ if (conversation.participants.some((user) => user.id === externalUserId)) {
286
+ return true;
287
+ }
288
+ }
289
+ for (const event of payload.events) {
290
+ if (
291
+ event.senderId === externalUserId ||
292
+ event.recipientId === externalUserId
293
+ ) {
294
+ return true;
295
+ }
296
+ if (
297
+ event.sender?.id === externalUserId ||
298
+ event.recipient?.id === externalUserId
299
+ ) {
300
+ return true;
301
+ }
302
+ if (
303
+ event.conversationId &&
304
+ conversationIdReferencesExternalUserId(
305
+ event.conversationId,
306
+ externalUserId,
307
+ )
308
+ ) {
309
+ return true;
310
+ }
311
+ }
312
+ return false;
313
+ }
314
+
315
+ function ensureSparseLocalProfile(
316
+ db: Database,
317
+ externalUserId: string,
318
+ accountUsername: string,
319
+ ) {
320
+ const profileId = buildExternalProfileId(externalUserId);
321
+ const existing = db
322
+ .prepare("select id from profiles where id = ? or handle = ? limit 1")
323
+ .get(profileId, accountUsername) as { id: string } | undefined;
324
+ if (existing) {
325
+ return existing.id;
326
+ }
327
+
328
+ const createdAt = new Date().toISOString();
329
+ db.prepare(
330
+ `
331
+ insert into profiles (
332
+ id, handle, display_name, bio, followers_count, following_count,
333
+ public_metrics_json, avatar_hue, entities_json, raw_json, created_at
334
+ ) values (?, ?, ?, '', 0, 0, '{}', ?, '{}', '{}', ?)
335
+ `,
336
+ ).run(
337
+ profileId,
338
+ accountUsername,
339
+ accountUsername,
340
+ randomAvatarHue(accountUsername),
341
+ createdAt,
342
+ );
343
+ return profileId;
344
+ }
345
+
114
346
  function mergeDirectMessagesIntoLocalStore(
115
347
  db: Database,
116
348
  accountId: string,
117
349
  accountUsername: string,
350
+ accountExternalUserId: string | undefined,
118
351
  payload: {
119
352
  conversations: BirdDmConversation[];
120
353
  events: BirdDmEvent[];
121
354
  },
122
355
  ) {
123
- const users = collectUsers(payload);
124
- const localExternalUserId = getLocalExternalUserId(users, accountUsername);
356
+ const users = collectUsers(payload, accountExternalUserId);
357
+ const localExternalUserId = getLocalExternalUserId(
358
+ users,
359
+ accountUsername,
360
+ accountExternalUserId,
361
+ );
362
+ if (
363
+ accountExternalUserId &&
364
+ (payload.conversations.length > 0 || payload.events.length > 0) &&
365
+ !payloadReferencesExternalUserId(payload, accountExternalUserId)
366
+ ) {
367
+ throw new Error(
368
+ `bird DM payload does not include @${accountUsername}; refusing to sync into ${accountId}`,
369
+ );
370
+ }
371
+ if (
372
+ !localExternalUserId &&
373
+ (payload.conversations.length > 0 || payload.events.length > 0)
374
+ ) {
375
+ throw new Error(
376
+ `bird DM payload does not include @${accountUsername}; refusing to sync into ${accountId}`,
377
+ );
378
+ }
379
+ if (!localExternalUserId) {
380
+ return;
381
+ }
125
382
  const profilesByExternalId = new Map<string, string>();
126
383
  for (const user of users.values()) {
127
384
  const resolved = upsertProfileFromXUser(db, toXUser(user));
128
385
  profilesByExternalId.set(user.id, resolved.profile.id);
129
386
  }
387
+ if (
388
+ accountExternalUserId &&
389
+ !profilesByExternalId.has(accountExternalUserId)
390
+ ) {
391
+ profilesByExternalId.set(
392
+ accountExternalUserId,
393
+ ensureSparseLocalProfile(db, accountExternalUserId, accountUsername),
394
+ );
395
+ }
130
396
 
131
397
  const eventsByConversation = new Map<string, BirdDmEvent[]>();
132
398
  for (const event of payload.events) {
@@ -138,12 +404,13 @@ function mergeDirectMessagesIntoLocalStore(
138
404
 
139
405
  const upsertConversation = db.prepare(`
140
406
  insert into dm_conversations (
141
- id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
142
- ) values (?, ?, ?, ?, ?, 0, ?)
407
+ id, account_id, participant_profile_id, title, inbox_kind, last_message_at, unread_count, needs_reply
408
+ ) values (?, ?, ?, ?, ?, ?, 0, ?)
143
409
  on conflict(id) do update set
144
410
  account_id = excluded.account_id,
145
411
  participant_profile_id = excluded.participant_profile_id,
146
412
  title = excluded.title,
413
+ inbox_kind = excluded.inbox_kind,
147
414
  last_message_at = excluded.last_message_at,
148
415
  needs_reply = excluded.needs_reply
149
416
  `);
@@ -159,15 +426,45 @@ function mergeDirectMessagesIntoLocalStore(
159
426
  direction = excluded.direction,
160
427
  media_count = excluded.media_count
161
428
  `);
162
- const replaceFts = db.prepare("delete from dm_fts where message_id = ?");
163
429
  const insertFts = db.prepare(
164
430
  "insert into dm_fts (message_id, text) values (?, ?)",
165
431
  );
432
+ const deleteMessage = db.prepare("delete from dm_messages where id = ?");
433
+ const ftsMessageIdsToReplace = new Set<string>();
434
+ for (const conversation of payload.conversations) {
435
+ const events = eventsByConversation.get(conversation.id) ?? [];
436
+ if (events.length === 0 && !conversation.lastMessagePreview) {
437
+ continue;
438
+ }
439
+ const participant =
440
+ conversation.participants.find(
441
+ (user) =>
442
+ user.id !== localExternalUserId &&
443
+ user.username?.toLowerCase() !== accountUsername.toLowerCase(),
444
+ ) ?? conversation.participants[0];
445
+ if (!participant || !profilesByExternalId.has(participant.id)) {
446
+ continue;
447
+ }
448
+ if (events.length === 0) {
449
+ ftsMessageIdsToReplace.add(makePreviewMessageId(conversation.id));
450
+ continue;
451
+ }
452
+ ftsMessageIdsToReplace.add(makePreviewMessageId(conversation.id));
453
+ for (const event of events) {
454
+ const senderId = event.senderId ?? event.sender?.id;
455
+ if (senderId && profilesByExternalId.has(senderId)) {
456
+ ftsMessageIdsToReplace.add(event.id);
457
+ }
458
+ }
459
+ }
166
460
 
167
461
  db.transaction(() => {
462
+ deleteDmFtsRows(db, [...ftsMessageIdsToReplace]);
463
+ const ftsTextByMessageId = new Map<string, string>();
464
+
168
465
  for (const conversation of payload.conversations) {
169
466
  const events = eventsByConversation.get(conversation.id) ?? [];
170
- if (events.length === 0) {
467
+ if (events.length === 0 && !conversation.lastMessagePreview) {
171
468
  continue;
172
469
  }
173
470
 
@@ -175,7 +472,7 @@ function mergeDirectMessagesIntoLocalStore(
175
472
  conversation.participants.find(
176
473
  (user) =>
177
474
  user.id !== localExternalUserId &&
178
- user.username !== accountUsername,
475
+ user.username?.toLowerCase() !== accountUsername.toLowerCase(),
179
476
  ) ?? conversation.participants[0];
180
477
  if (!participant) {
181
478
  continue;
@@ -189,18 +486,47 @@ function mergeDirectMessagesIntoLocalStore(
189
486
  const lastMessageAt = toIsoTimestamp(
190
487
  latest?.createdAt ?? conversation.lastMessageAt,
191
488
  );
192
- const latestInbound =
193
- latest?.senderId !== localExternalUserId &&
194
- latest?.sender?.username !== accountUsername;
489
+ const inboxKind =
490
+ conversation.inboxKind ??
491
+ (conversation.isMessageRequest ? "request" : "accepted");
492
+ const latestInbound = latest
493
+ ? latest.senderId !== localExternalUserId &&
494
+ latest.sender?.username?.toLowerCase() !==
495
+ accountUsername.toLowerCase()
496
+ : inboxKind === "request";
195
497
  upsertConversation.run(
196
498
  conversation.id,
197
499
  accountId,
198
500
  participantProfileId,
199
501
  participant.username ?? participant.name ?? participant.id,
502
+ inboxKind,
200
503
  lastMessageAt,
201
504
  latestInbound ? 1 : 0,
202
505
  );
203
506
 
507
+ const previewMessageId = makePreviewMessageId(conversation.id);
508
+ if (events.length === 0 && conversation.lastMessagePreview) {
509
+ const previewSenderProfileId = latestInbound
510
+ ? participantProfileId
511
+ : (profilesByExternalId.get(localExternalUserId) ??
512
+ participantProfileId);
513
+ upsertMessage.run(
514
+ previewMessageId,
515
+ conversation.id,
516
+ previewSenderProfileId,
517
+ conversation.lastMessagePreview,
518
+ lastMessageAt,
519
+ latestInbound ? "inbound" : "outbound",
520
+ );
521
+ ftsTextByMessageId.set(
522
+ previewMessageId,
523
+ conversation.lastMessagePreview,
524
+ );
525
+ continue;
526
+ }
527
+
528
+ deleteMessage.run(previewMessageId);
529
+
204
530
  for (const event of events) {
205
531
  const senderId = event.senderId ?? event.sender?.id;
206
532
  if (!senderId) {
@@ -212,7 +538,8 @@ function mergeDirectMessagesIntoLocalStore(
212
538
  }
213
539
  const direction =
214
540
  senderId === localExternalUserId ||
215
- event.sender?.username === accountUsername
541
+ event.sender?.username?.toLowerCase() ===
542
+ accountUsername.toLowerCase()
216
543
  ? "outbound"
217
544
  : "inbound";
218
545
  upsertMessage.run(
@@ -223,57 +550,384 @@ function mergeDirectMessagesIntoLocalStore(
223
550
  toIsoTimestamp(event.createdAt),
224
551
  direction,
225
552
  );
226
- replaceFts.run(event.id);
227
- insertFts.run(event.id, event.text);
553
+ ftsTextByMessageId.set(event.id, event.text);
228
554
  }
229
555
  }
556
+
557
+ for (const [messageId, text] of ftsTextByMessageId) {
558
+ insertFts.run(messageId, text);
559
+ }
230
560
  })();
231
561
  }
232
562
 
233
- export async function syncDirectMessagesViaCachedBird({
234
- account,
235
- limit = 20,
236
- refresh = false,
237
- cacheTtlMs,
563
+ function getMetaNextToken(meta?: Record<string, unknown>) {
564
+ const token = meta?.next_token;
565
+ return typeof token === "string" && token.length > 0 ? token : undefined;
566
+ }
567
+
568
+ function xurlUserToBirdDmUser(user: XurlMentionUser): BirdDmUser {
569
+ return {
570
+ id: String(user.id),
571
+ username: user.username,
572
+ name: user.name,
573
+ profileImageUrl: user.profile_image_url,
574
+ };
575
+ }
576
+
577
+ function uniqueDefined(values: Array<string | undefined>) {
578
+ return [
579
+ ...new Set(values.filter((value): value is string => Boolean(value))),
580
+ ];
581
+ }
582
+
583
+ function conversationIdForXurlEvent(
584
+ event: XurlDmEventsResponse["data"][number],
585
+ localExternalUserId: string,
586
+ ) {
587
+ if (event.dm_conversation_id) {
588
+ return event.dm_conversation_id;
589
+ }
590
+ const ids = uniqueDefined([
591
+ localExternalUserId,
592
+ event.sender_id,
593
+ ...(event.participant_ids ?? []),
594
+ ]).sort();
595
+ return ids.length > 1 ? ids.join("-") : `dm-${event.id}`;
596
+ }
597
+
598
+ function adaptXurlDmEventsToBirdPayload({
599
+ payload,
600
+ localExternalUserId,
601
+ accountUsername,
238
602
  }: {
239
- account?: string;
240
- limit?: number;
241
- refresh?: boolean;
242
- cacheTtlMs?: number;
243
- }) {
244
- assertBirdLimit(limit);
245
- const db = getNativeDb();
246
- const resolvedAccount = resolveAccount(db, account);
247
- const cacheKey = `dms:bird:${resolvedAccount.accountId}:${String(limit)}`;
248
- const ttlMs = parseCacheTtlMs(cacheTtlMs);
249
- const cached = readSyncCache<{
250
- conversations: BirdDmConversation[];
251
- events: BirdDmEvent[];
252
- }>(cacheKey, db);
253
- const cacheAgeMs = cached
254
- ? Date.now() - new Date(cached.updatedAt).getTime()
255
- : Number.POSITIVE_INFINITY;
256
-
257
- const payload =
258
- !refresh && cached && cacheAgeMs <= ttlMs
259
- ? cached.value
260
- : await listDirectMessagesViaBird({ maxResults: limit });
261
-
262
- mergeDirectMessagesIntoLocalStore(
263
- db,
264
- resolvedAccount.accountId,
265
- resolvedAccount.username,
266
- payload,
267
- );
268
- if (!cached || refresh || cacheAgeMs > ttlMs) {
269
- writeSyncCache(cacheKey, payload, db);
603
+ payload: XurlDmEventsResponse;
604
+ localExternalUserId: string;
605
+ accountUsername: string;
606
+ }): { conversations: BirdDmConversation[]; events: BirdDmEvent[] } {
607
+ const users = new Map<string, BirdDmUser>();
608
+ const addUser = (user?: BirdDmUser) => {
609
+ if (!user?.id) return;
610
+ users.set(user.id, { ...users.get(user.id), ...user });
611
+ };
612
+ for (const user of payload.includes?.users ?? []) {
613
+ addUser(xurlUserToBirdDmUser(user));
270
614
  }
615
+ addUser({
616
+ id: localExternalUserId,
617
+ username: accountUsername,
618
+ name: accountUsername,
619
+ });
271
620
 
621
+ const eventsByConversation = new Map<string, BirdDmEvent[]>();
622
+ const events: BirdDmEvent[] = [];
623
+ for (const event of payload.data) {
624
+ if (event.event_type && event.event_type !== "MessageCreate") continue;
625
+ if (!event.id || !event.sender_id || event.text === undefined) continue;
626
+ const conversationId = conversationIdForXurlEvent(
627
+ event,
628
+ localExternalUserId,
629
+ );
630
+ const participantIds = uniqueDefined([
631
+ localExternalUserId,
632
+ event.sender_id,
633
+ ...(event.participant_ids ?? []),
634
+ ]);
635
+ for (const id of participantIds) {
636
+ addUser({ id });
637
+ }
638
+ const recipientId =
639
+ event.sender_id === localExternalUserId
640
+ ? participantIds.find((id) => id !== localExternalUserId)
641
+ : localExternalUserId;
642
+ const dmEvent: BirdDmEvent = {
643
+ id: event.id,
644
+ conversationId,
645
+ text: event.text,
646
+ createdAt: event.created_at,
647
+ senderId: event.sender_id,
648
+ ...(recipientId ? { recipientId } : {}),
649
+ sender: users.get(event.sender_id) ?? { id: event.sender_id },
650
+ ...(recipientId
651
+ ? { recipient: users.get(recipientId) ?? { id: recipientId } }
652
+ : {}),
653
+ inboxKind: "accepted",
654
+ isMessageRequest: false,
655
+ };
656
+ events.push(dmEvent);
657
+ const conversationEvents = eventsByConversation.get(conversationId) ?? [];
658
+ conversationEvents.push(dmEvent);
659
+ eventsByConversation.set(conversationId, conversationEvents);
660
+ }
661
+
662
+ const conversations = [...eventsByConversation].map(
663
+ ([conversationId, conversationEvents]) => {
664
+ const latest = getLatestEvent(conversationEvents);
665
+ const participantIds = uniqueDefined([
666
+ localExternalUserId,
667
+ ...conversationEvents.flatMap((event) => [
668
+ event.senderId,
669
+ event.recipientId,
670
+ ]),
671
+ ]);
672
+ const participants = participantIds.map((id) => users.get(id) ?? { id });
673
+ return {
674
+ id: conversationId,
675
+ participants,
676
+ messages: conversationEvents,
677
+ lastMessageAt: latest?.createdAt,
678
+ lastMessagePreview: latest?.text,
679
+ inboxKind: "accepted" as const,
680
+ isMessageRequest: false,
681
+ };
682
+ },
683
+ );
684
+
685
+ return { conversations, events };
686
+ }
687
+
688
+ function mergeXurlDmPages(pages: XurlDmEventsResponse[]): XurlDmEventsResponse {
689
+ const usersById = new Map<string, XurlMentionUser>();
690
+ const eventsById = new Map<string, XurlDmEventsResponse["data"][number]>();
691
+ let meta: Record<string, unknown> | undefined;
692
+ for (const page of pages) {
693
+ for (const user of page.includes?.users ?? []) {
694
+ usersById.set(user.id, { ...usersById.get(user.id), ...user });
695
+ }
696
+ for (const event of page.data) {
697
+ eventsById.set(event.id, event);
698
+ }
699
+ meta = page.meta ?? meta;
700
+ }
272
701
  return {
273
- ok: true,
274
- source: cached && !refresh && cacheAgeMs <= ttlMs ? "cache" : "bird",
275
- accountId: resolvedAccount.accountId,
276
- conversations: payload.conversations.length,
277
- messages: payload.events.length,
702
+ data: [...eventsById.values()],
703
+ ...(usersById.size > 0
704
+ ? { includes: { users: [...usersById.values()] } }
705
+ : {}),
706
+ ...(meta ? { meta } : {}),
278
707
  };
279
708
  }
709
+
710
+ function sleepEffect(ms: number | undefined) {
711
+ return typeof ms === "number" && ms > 0 ? Effect.sleep(ms) : Effect.void;
712
+ }
713
+
714
+ function fetchDirectMessagesViaXurlEffect({
715
+ limit,
716
+ username,
717
+ maxPages,
718
+ allPages,
719
+ pageDelayMs,
720
+ }: {
721
+ limit: number;
722
+ username: string;
723
+ maxPages?: number;
724
+ allPages: boolean;
725
+ pageDelayMs?: number;
726
+ }) {
727
+ return Effect.gen(function* () {
728
+ const pages: XurlDmEventsResponse[] = [];
729
+ let paginationToken: string | undefined;
730
+ let pageIndex = 0;
731
+ const pageLimit = allPages
732
+ ? Number.POSITIVE_INFINITY
733
+ : Math.max(1, (maxPages ?? 0) + 1);
734
+ while (pageIndex < pageLimit) {
735
+ const page = yield* listDirectMessageEventsViaXurlEffect({
736
+ maxResults: limit,
737
+ username,
738
+ ...(paginationToken ? { paginationToken } : {}),
739
+ });
740
+ pages.push(page);
741
+ pageIndex += 1;
742
+ paginationToken = getMetaNextToken(page.meta);
743
+ if (!paginationToken) break;
744
+ yield* sleepEffect(pageDelayMs);
745
+ }
746
+ return mergeXurlDmPages(pages);
747
+ });
748
+ }
749
+
750
+ export function syncDirectMessagesViaCachedBirdEffect({
751
+ account,
752
+ mode,
753
+ limit = 20,
754
+ inbox = "all",
755
+ maxPages,
756
+ allPages = false,
757
+ pageDelayMs,
758
+ refresh = false,
759
+ cacheTtlMs,
760
+ }: SyncDirectMessagesViaCachedBirdOptions = {}): Effect.Effect<
761
+ {
762
+ ok: true;
763
+ source: "bird" | "cache" | "xurl";
764
+ accountId: string;
765
+ conversations: number;
766
+ messages: number;
767
+ },
768
+ unknown
769
+ > {
770
+ return Effect.gen(function* () {
771
+ const parsedMode = parseSyncMode(mode);
772
+ if (parsedMode === "xurl") {
773
+ assertXurlLimit(limit);
774
+ } else {
775
+ assertBirdLimit(limit);
776
+ }
777
+ if (inbox === "requests" && parsedMode === "xurl") {
778
+ throw new Error(
779
+ "xurl DM mode cannot read the message-request inbox or accept/reject state; use --mode bird",
780
+ );
781
+ }
782
+ const db = getNativeDb();
783
+ const resolvedAccount = resolveAccount(db, account);
784
+ const pageKey = allPages
785
+ ? "all-pages"
786
+ : `max-pages:${String(maxPages ?? 0)}`;
787
+ const cacheMode = parsedMode === "auto" ? "auto" : parsedMode;
788
+ const cacheKey = `dms:${cacheMode}:${resolvedAccount.accountId}:${String(limit)}:${inbox}:${pageKey}`;
789
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
790
+ const cached = readSyncCache<{
791
+ conversations: BirdDmConversation[];
792
+ events: BirdDmEvent[];
793
+ }>(cacheKey, db);
794
+ const cacheAgeMs = cached
795
+ ? Date.now() - new Date(cached.updatedAt).getTime()
796
+ : Number.POSITIVE_INFINITY;
797
+
798
+ const cacheHit = !refresh && cached && cacheAgeMs <= ttlMs;
799
+ let accountExternalUserId = resolvedAccount.externalUserId;
800
+ let payload:
801
+ | {
802
+ conversations: BirdDmConversation[];
803
+ events: BirdDmEvent[];
804
+ }
805
+ | undefined;
806
+ let source: "bird" | "xurl" | undefined;
807
+ if (cacheHit) {
808
+ payload = cached.value;
809
+ } else {
810
+ const tryXurl =
811
+ (parsedMode === "xurl" || parsedMode === "auto") &&
812
+ inbox !== "requests";
813
+ if (tryXurl) {
814
+ const xurlPayload = yield* Effect.gen(function* () {
815
+ const authenticated = getAuthenticatedXurlAccount(
816
+ yield* lookupAuthenticatedOAuth2UserEffect(
817
+ resolvedAccount.username,
818
+ ),
819
+ );
820
+ if (!authenticated.username && !authenticated.id) {
821
+ return yield* Effect.fail(
822
+ new Error("xurl authenticated user unavailable"),
823
+ );
824
+ }
825
+ assertAuthenticatedBirdAccountMatches({
826
+ source: "xurl",
827
+ accountId: resolvedAccount.accountId,
828
+ username: resolvedAccount.username,
829
+ externalUserId: resolvedAccount.externalUserId,
830
+ liveUsername: authenticated.username ?? resolvedAccount.username,
831
+ liveExternalUserId: authenticated.id,
832
+ });
833
+ accountExternalUserId ??= authenticated.id;
834
+ if (!resolvedAccount.externalUserId && accountExternalUserId) {
835
+ persistAccountExternalUserId(
836
+ db,
837
+ resolvedAccount.accountId,
838
+ accountExternalUserId,
839
+ );
840
+ }
841
+ if (!accountExternalUserId) {
842
+ return yield* Effect.fail(
843
+ new Error(
844
+ "xurl authenticated user id unavailable; refusing to sync DMs",
845
+ ),
846
+ );
847
+ }
848
+ return yield* fetchDirectMessagesViaXurlEffect({
849
+ limit,
850
+ username: resolvedAccount.username,
851
+ ...(typeof maxPages === "number" ? { maxPages } : {}),
852
+ allPages,
853
+ ...(typeof pageDelayMs === "number" ? { pageDelayMs } : {}),
854
+ });
855
+ }).pipe(
856
+ Effect.catchAll((error) => {
857
+ if (parsedMode === "xurl") return Effect.fail(error);
858
+ return Effect.succeed(undefined);
859
+ }),
860
+ );
861
+ if (xurlPayload) {
862
+ const localExternalUserId = accountExternalUserId;
863
+ if (!localExternalUserId) {
864
+ throw new Error(
865
+ "xurl authenticated user id unavailable; refusing to sync DMs",
866
+ );
867
+ }
868
+ payload = adaptXurlDmEventsToBirdPayload({
869
+ payload: xurlPayload,
870
+ localExternalUserId,
871
+ accountUsername: resolvedAccount.username,
872
+ });
873
+ source = "xurl";
874
+ }
875
+ }
876
+ if (!payload) {
877
+ const authenticated = yield* getAuthenticatedBirdAccountEffect();
878
+ assertAuthenticatedBirdAccountMatches({
879
+ source: "bird",
880
+ accountId: resolvedAccount.accountId,
881
+ username: resolvedAccount.username,
882
+ externalUserId: resolvedAccount.externalUserId,
883
+ liveUsername: authenticated.username,
884
+ liveExternalUserId: authenticated.id,
885
+ });
886
+ accountExternalUserId ??= authenticated.id;
887
+ if (!resolvedAccount.externalUserId && accountExternalUserId) {
888
+ persistAccountExternalUserId(
889
+ db,
890
+ resolvedAccount.accountId,
891
+ accountExternalUserId,
892
+ );
893
+ }
894
+ payload = yield* listDirectMessagesViaBirdEffect({
895
+ maxResults: limit,
896
+ ...(inbox !== "all" ? { inbox } : {}),
897
+ ...(typeof maxPages === "number" ? { maxPages } : {}),
898
+ ...(allPages ? { allPages } : {}),
899
+ ...(typeof pageDelayMs === "number" ? { pageDelayMs } : {}),
900
+ });
901
+ source = "bird";
902
+ }
903
+ }
904
+ if (!payload) {
905
+ throw new Error("DM sync produced no payload");
906
+ }
907
+
908
+ mergeDirectMessagesIntoLocalStore(
909
+ db,
910
+ resolvedAccount.accountId,
911
+ resolvedAccount.username,
912
+ accountExternalUserId,
913
+ payload,
914
+ );
915
+ if (!cached || refresh || cacheAgeMs > ttlMs) {
916
+ writeSyncCache(cacheKey, payload, db);
917
+ }
918
+
919
+ return {
920
+ ok: true,
921
+ source: cacheHit ? "cache" : source!,
922
+ accountId: resolvedAccount.accountId,
923
+ conversations: payload.conversations.length,
924
+ messages: payload.events.length,
925
+ } as const;
926
+ });
927
+ }
928
+
929
+ export function syncDirectMessagesViaCachedBird(
930
+ options: SyncDirectMessagesViaCachedBirdOptions = {},
931
+ ) {
932
+ return runEffectPromise(syncDirectMessagesViaCachedBirdEffect(options));
933
+ }