birdclaw 0.1.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 (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,725 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type Database from "better-sqlite3";
3
+ import { findArchives } from "./archive-finder";
4
+ import { getDb, getNativeDb } from "./db";
5
+ import type {
6
+ AccountRecord,
7
+ DmConversationItem,
8
+ DmMessageItem,
9
+ DmQuery,
10
+ EmbeddedTweet,
11
+ ProfileRecord,
12
+ QueryEnvelope,
13
+ QueryResponse,
14
+ ReplyFilter,
15
+ TimelineItem,
16
+ TimelineQuery,
17
+ TweetEntities,
18
+ TweetMediaItem,
19
+ } from "./types";
20
+ import {
21
+ dmViaXurl,
22
+ getTransportStatus,
23
+ postViaXurl,
24
+ replyViaXurl,
25
+ } from "./xurl";
26
+
27
+ function getInfluenceScore(followersCount: number) {
28
+ return Math.round(Math.log10(followersCount + 10) * 24);
29
+ }
30
+
31
+ function getInfluenceLabel(score: number) {
32
+ if (score >= 150) return "very high";
33
+ if (score >= 120) return "high";
34
+ if (score >= 90) return "medium";
35
+ return "emerging";
36
+ }
37
+
38
+ function toProfile(row: Record<string, unknown>): ProfileRecord {
39
+ return {
40
+ id: String(row.id),
41
+ handle: String(row.handle),
42
+ displayName: String(row.display_name),
43
+ bio: String(row.bio),
44
+ followersCount: Number(row.followers_count),
45
+ avatarHue: Number(row.avatar_hue),
46
+ avatarUrl:
47
+ typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
48
+ createdAt: String(row.created_at),
49
+ };
50
+ }
51
+
52
+ function parseJsonField<T>(value: unknown, fallback: T): T {
53
+ if (typeof value !== "string" || value.length === 0) {
54
+ return fallback;
55
+ }
56
+
57
+ try {
58
+ return JSON.parse(value) as T;
59
+ } catch {
60
+ return fallback;
61
+ }
62
+ }
63
+
64
+ function enrichEntities(
65
+ entities: TweetEntities,
66
+ profiles: Record<string, ProfileRecord>,
67
+ ): TweetEntities {
68
+ const mentions = entities.mentions?.map((mention) => {
69
+ const profile =
70
+ (mention.id ? profiles[mention.id] : undefined) ??
71
+ Object.values(profiles).find(
72
+ (candidate) => candidate.handle === mention.username,
73
+ );
74
+ return profile ? { ...mention, profile } : mention;
75
+ });
76
+
77
+ return {
78
+ ...entities,
79
+ ...(mentions ? { mentions } : {}),
80
+ };
81
+ }
82
+
83
+ function buildEmbeddedTweet(
84
+ row: Record<string, unknown>,
85
+ prefix: string,
86
+ ): EmbeddedTweet | null {
87
+ if (!row[`${prefix}id`]) {
88
+ return null;
89
+ }
90
+
91
+ const author = toProfile({
92
+ id: row[`${prefix}profile_id`],
93
+ handle: row[`${prefix}handle`],
94
+ display_name: row[`${prefix}display_name`],
95
+ bio: row[`${prefix}bio`],
96
+ followers_count: row[`${prefix}followers_count`],
97
+ avatar_hue: row[`${prefix}avatar_hue`],
98
+ avatar_url: row[`${prefix}avatar_url`],
99
+ created_at: row[`${prefix}profile_created_at`],
100
+ });
101
+
102
+ return {
103
+ id: String(row[`${prefix}id`]),
104
+ text: String(row[`${prefix}text`] ?? ""),
105
+ createdAt: String(row[`${prefix}created_at`] ?? new Date(0).toISOString()),
106
+ author,
107
+ entities: enrichEntities(
108
+ parseJsonField<TweetEntities>(row[`${prefix}entities_json`], {}),
109
+ {
110
+ [author.id]: author,
111
+ },
112
+ ),
113
+ media: parseJsonField<TweetMediaItem[]>(row[`${prefix}media_json`], []),
114
+ };
115
+ }
116
+
117
+ function buildReplyClause(replyFilter: ReplyFilter) {
118
+ if (replyFilter === "replied") {
119
+ return " and is_replied = 1";
120
+ }
121
+ if (replyFilter === "unreplied") {
122
+ return " and is_replied = 0";
123
+ }
124
+ return "";
125
+ }
126
+
127
+ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
128
+ const db = getDb();
129
+ const counts = await Promise.all([
130
+ db
131
+ .selectFrom("tweets")
132
+ .select((eb) => eb.fn.countAll().as("count"))
133
+ .where("kind", "=", "home")
134
+ .executeTakeFirstOrThrow(),
135
+ db
136
+ .selectFrom("tweets")
137
+ .select((eb) => eb.fn.countAll().as("count"))
138
+ .where("kind", "=", "mention")
139
+ .executeTakeFirstOrThrow(),
140
+ db
141
+ .selectFrom("dm_conversations")
142
+ .select((eb) => eb.fn.countAll().as("count"))
143
+ .executeTakeFirstOrThrow(),
144
+ db
145
+ .selectFrom("dm_conversations")
146
+ .select((eb) => eb.fn.countAll().as("count"))
147
+ .where("needs_reply", "=", 1)
148
+ .executeTakeFirstOrThrow(),
149
+ db
150
+ .selectFrom("accounts")
151
+ .selectAll()
152
+ .orderBy("is_default", "desc")
153
+ .orderBy("name", "asc")
154
+ .execute(),
155
+ findArchives(),
156
+ getTransportStatus(),
157
+ ]);
158
+
159
+ return {
160
+ stats: {
161
+ home: Number(counts[0].count),
162
+ mentions: Number(counts[1].count),
163
+ dms: Number(counts[2].count),
164
+ needsReply: Number(counts[3].count),
165
+ inbox: Number(counts[1].count) + Number(counts[3].count),
166
+ },
167
+ accounts: counts[4].map((row) => ({
168
+ id: row.id,
169
+ name: row.name,
170
+ handle: row.handle,
171
+ transport: row.transport,
172
+ isDefault: row.is_default,
173
+ createdAt: row.created_at,
174
+ })) satisfies AccountRecord[],
175
+ archives: counts[5],
176
+ transport: counts[6],
177
+ };
178
+ }
179
+
180
+ export function listTimelineItems({
181
+ resource,
182
+ account,
183
+ search,
184
+ replyFilter = "all",
185
+ limit = 18,
186
+ }: TimelineQuery): TimelineItem[] {
187
+ const db = getNativeDb();
188
+ const kind = resource === "mentions" ? "mention" : resource;
189
+ const params: Array<string | number> = [kind];
190
+ let join = "";
191
+ let where = "where t.kind = ?";
192
+
193
+ if (account && account !== "all") {
194
+ where += " and a.id = ?";
195
+ params.push(account);
196
+ }
197
+
198
+ where += buildReplyClause(replyFilter).replaceAll(
199
+ "is_replied",
200
+ "t.is_replied",
201
+ );
202
+
203
+ if (search?.trim()) {
204
+ join += " join tweets_fts fts on fts.tweet_id = t.id ";
205
+ where += " and fts.text match ?";
206
+ params.push(search.trim());
207
+ }
208
+
209
+ params.push(limit);
210
+
211
+ const rows = db
212
+ .prepare(
213
+ `
214
+ select
215
+ t.id,
216
+ t.account_id,
217
+ a.handle as account_handle,
218
+ t.kind,
219
+ t.text,
220
+ t.created_at,
221
+ t.is_replied,
222
+ t.like_count,
223
+ t.media_count,
224
+ t.bookmarked,
225
+ t.liked,
226
+ t.entities_json,
227
+ t.media_json,
228
+ t.quoted_tweet_id,
229
+ p.id as profile_id,
230
+ p.handle,
231
+ p.display_name,
232
+ p.bio,
233
+ p.followers_count,
234
+ p.avatar_hue,
235
+ p.avatar_url,
236
+ p.created_at as profile_created_at,
237
+ rt.id as reply_id,
238
+ rt.text as reply_text,
239
+ rt.created_at as reply_created_at,
240
+ rt.entities_json as reply_entities_json,
241
+ rt.media_json as reply_media_json,
242
+ rp.id as reply_profile_id,
243
+ rp.handle as reply_handle,
244
+ rp.display_name as reply_display_name,
245
+ rp.bio as reply_bio,
246
+ rp.followers_count as reply_followers_count,
247
+ rp.avatar_hue as reply_avatar_hue,
248
+ rp.avatar_url as reply_avatar_url,
249
+ rp.created_at as reply_profile_created_at,
250
+ qt.id as quoted_id,
251
+ qt.text as quoted_text,
252
+ qt.created_at as quoted_created_at,
253
+ qt.entities_json as quoted_entities_json,
254
+ qt.media_json as quoted_media_json,
255
+ qp.id as quoted_profile_id,
256
+ qp.handle as quoted_handle,
257
+ qp.display_name as quoted_display_name,
258
+ qp.bio as quoted_bio,
259
+ qp.followers_count as quoted_followers_count,
260
+ qp.avatar_hue as quoted_avatar_hue,
261
+ qp.avatar_url as quoted_avatar_url,
262
+ qp.created_at as quoted_profile_created_at
263
+ from tweets t
264
+ join accounts a on a.id = t.account_id
265
+ join profiles p on p.id = t.author_profile_id
266
+ left join tweets rt on rt.id = t.reply_to_id
267
+ left join profiles rp on rp.id = rt.author_profile_id
268
+ left join tweets qt on qt.id = t.quoted_tweet_id
269
+ left join profiles qp on qp.id = qt.author_profile_id
270
+ ${join}
271
+ ${where}
272
+ order by t.created_at desc
273
+ limit ?
274
+ `,
275
+ )
276
+ .all(...params) as Array<Record<string, unknown>>;
277
+
278
+ return rows.map((row) => {
279
+ const author = {
280
+ id: String(row.profile_id),
281
+ handle: String(row.handle),
282
+ displayName: String(row.display_name),
283
+ bio: String(row.bio),
284
+ followersCount: Number(row.followers_count),
285
+ avatarHue: Number(row.avatar_hue),
286
+ avatarUrl:
287
+ typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
288
+ createdAt: String(row.profile_created_at),
289
+ };
290
+ const entities = enrichEntities(
291
+ parseJsonField<TweetEntities>(row.entities_json, {}),
292
+ {
293
+ [author.id]: author,
294
+ ...(row.reply_profile_id
295
+ ? {
296
+ [String(row.reply_profile_id)]: toProfile({
297
+ id: row.reply_profile_id,
298
+ handle: row.reply_handle,
299
+ display_name: row.reply_display_name,
300
+ bio: row.reply_bio,
301
+ followers_count: row.reply_followers_count,
302
+ avatar_hue: row.reply_avatar_hue,
303
+ avatar_url: row.reply_avatar_url,
304
+ created_at: row.reply_profile_created_at,
305
+ }),
306
+ }
307
+ : {}),
308
+ ...(row.quoted_profile_id
309
+ ? {
310
+ [String(row.quoted_profile_id)]: toProfile({
311
+ id: row.quoted_profile_id,
312
+ handle: row.quoted_handle,
313
+ display_name: row.quoted_display_name,
314
+ bio: row.quoted_bio,
315
+ followers_count: row.quoted_followers_count,
316
+ avatar_hue: row.quoted_avatar_hue,
317
+ avatar_url: row.quoted_avatar_url,
318
+ created_at: row.quoted_profile_created_at,
319
+ }),
320
+ }
321
+ : {}),
322
+ },
323
+ );
324
+ return {
325
+ id: String(row.id),
326
+ accountId: String(row.account_id),
327
+ accountHandle: String(row.account_handle),
328
+ kind: row.kind as TimelineItem["kind"],
329
+ text: String(row.text),
330
+ createdAt: String(row.created_at),
331
+ isReplied: Boolean(row.is_replied),
332
+ likeCount: Number(row.like_count),
333
+ mediaCount: Number(row.media_count),
334
+ bookmarked: Boolean(row.bookmarked),
335
+ liked: Boolean(row.liked),
336
+ author,
337
+ entities,
338
+ media: parseJsonField<TweetMediaItem[]>(row.media_json, []),
339
+ replyToTweet: buildEmbeddedTweet(row, "reply_"),
340
+ quotedTweet: buildEmbeddedTweet(row, "quoted_"),
341
+ };
342
+ });
343
+ }
344
+
345
+ export function listDmConversations({
346
+ account,
347
+ participant,
348
+ search,
349
+ replyFilter = "all",
350
+ minFollowers,
351
+ maxFollowers,
352
+ minInfluenceScore,
353
+ maxInfluenceScore,
354
+ sort = "recent",
355
+ limit = 20,
356
+ }: DmQuery): DmConversationItem[] {
357
+ const db = getNativeDb();
358
+ const params: Array<string | number> = [];
359
+ let join = "";
360
+ let where = "where 1 = 1";
361
+
362
+ if (account && account !== "all") {
363
+ where += " and a.id = ?";
364
+ params.push(account);
365
+ }
366
+
367
+ if (participant?.trim()) {
368
+ where += " and (p.handle like ? or p.display_name like ?)";
369
+ params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
370
+ }
371
+
372
+ if (replyFilter === "replied") {
373
+ where += " and c.needs_reply = 0";
374
+ } else if (replyFilter === "unreplied") {
375
+ where += " and c.needs_reply = 1";
376
+ }
377
+
378
+ if (typeof minFollowers === "number") {
379
+ where += " and p.followers_count >= ?";
380
+ params.push(minFollowers);
381
+ }
382
+
383
+ if (typeof maxFollowers === "number") {
384
+ where += " and p.followers_count <= ?";
385
+ params.push(maxFollowers);
386
+ }
387
+
388
+ if (search?.trim()) {
389
+ join +=
390
+ " join dm_messages latest_search on latest_search.conversation_id = c.id ";
391
+ join += " join dm_fts dmfts on dmfts.message_id = latest_search.id ";
392
+ where += " and dmfts.text match ?";
393
+ params.push(search.trim());
394
+ }
395
+
396
+ params.push(limit);
397
+
398
+ const rows = db
399
+ .prepare(
400
+ `
401
+ select
402
+ c.id,
403
+ c.account_id,
404
+ a.handle as account_handle,
405
+ c.title,
406
+ c.last_message_at,
407
+ c.unread_count,
408
+ c.needs_reply,
409
+ p.id as profile_id,
410
+ p.handle,
411
+ p.display_name,
412
+ p.bio,
413
+ p.followers_count,
414
+ p.avatar_hue,
415
+ p.avatar_url,
416
+ p.created_at as profile_created_at,
417
+ (
418
+ select text
419
+ from dm_messages latest_message
420
+ where latest_message.conversation_id = c.id
421
+ order by latest_message.created_at desc
422
+ limit 1
423
+ ) as last_message_preview
424
+ from dm_conversations c
425
+ join accounts a on a.id = c.account_id
426
+ join profiles p on p.id = c.participant_profile_id
427
+ ${join}
428
+ ${where}
429
+ group by c.id
430
+ order by c.last_message_at desc
431
+ limit ?
432
+ `,
433
+ )
434
+ .all(...params) as Array<Record<string, unknown>>;
435
+
436
+ const items = rows.map((row) => {
437
+ const followersCount = Number(row.followers_count);
438
+ const influenceScore = getInfluenceScore(followersCount);
439
+ return {
440
+ id: String(row.id),
441
+ accountId: String(row.account_id),
442
+ accountHandle: String(row.account_handle),
443
+ title: String(row.title),
444
+ lastMessageAt: String(row.last_message_at),
445
+ lastMessagePreview: String(row.last_message_preview ?? ""),
446
+ unreadCount: Number(row.unread_count),
447
+ needsReply: Boolean(row.needs_reply),
448
+ influenceScore,
449
+ influenceLabel: getInfluenceLabel(influenceScore),
450
+ participant: {
451
+ id: String(row.profile_id),
452
+ handle: String(row.handle),
453
+ displayName: String(row.display_name),
454
+ bio: String(row.bio),
455
+ followersCount,
456
+ avatarHue: Number(row.avatar_hue),
457
+ avatarUrl:
458
+ typeof row.avatar_url === "string"
459
+ ? String(row.avatar_url)
460
+ : undefined,
461
+ createdAt: String(row.profile_created_at),
462
+ },
463
+ };
464
+ });
465
+
466
+ const filtered = items.filter((item) => {
467
+ if (
468
+ typeof minInfluenceScore === "number" &&
469
+ item.influenceScore < minInfluenceScore
470
+ ) {
471
+ return false;
472
+ }
473
+
474
+ if (
475
+ typeof maxInfluenceScore === "number" &&
476
+ item.influenceScore > maxInfluenceScore
477
+ ) {
478
+ return false;
479
+ }
480
+
481
+ return true;
482
+ });
483
+
484
+ if (sort === "influence") {
485
+ filtered.sort((left, right) => {
486
+ if (
487
+ right.participant.followersCount !== left.participant.followersCount
488
+ ) {
489
+ return (
490
+ right.participant.followersCount - left.participant.followersCount
491
+ );
492
+ }
493
+ return (
494
+ new Date(right.lastMessageAt).getTime() -
495
+ new Date(left.lastMessageAt).getTime()
496
+ );
497
+ });
498
+ }
499
+
500
+ return filtered.slice(0, limit);
501
+ }
502
+
503
+ export function getConversationThread(
504
+ conversationId: string,
505
+ ): { conversation: DmConversationItem; messages: DmMessageItem[] } | null {
506
+ const conversation = listDmConversations({ limit: 100 }).find(
507
+ (item) => item.id === conversationId,
508
+ );
509
+
510
+ if (!conversation) {
511
+ return null;
512
+ }
513
+
514
+ const db = getNativeDb();
515
+ const rows = db
516
+ .prepare(
517
+ `
518
+ select
519
+ m.id,
520
+ m.conversation_id,
521
+ m.text,
522
+ m.created_at,
523
+ m.direction,
524
+ m.is_replied,
525
+ m.media_count,
526
+ p.id as profile_id,
527
+ p.handle,
528
+ p.display_name,
529
+ p.bio,
530
+ p.followers_count,
531
+ p.avatar_hue,
532
+ p.avatar_url,
533
+ p.created_at as profile_created_at
534
+ from dm_messages m
535
+ join profiles p on p.id = m.sender_profile_id
536
+ where m.conversation_id = ?
537
+ order by m.created_at asc
538
+ `,
539
+ )
540
+ .all(conversationId) as Array<Record<string, unknown>>;
541
+
542
+ return {
543
+ conversation,
544
+ messages: rows.map((row) => ({
545
+ id: String(row.id),
546
+ conversationId: String(row.conversation_id),
547
+ text: String(row.text),
548
+ createdAt: String(row.created_at),
549
+ direction: row.direction as DmMessageItem["direction"],
550
+ isReplied: Boolean(row.is_replied),
551
+ mediaCount: Number(row.media_count),
552
+ sender: toProfile({
553
+ id: row.profile_id,
554
+ handle: row.handle,
555
+ display_name: row.display_name,
556
+ bio: row.bio,
557
+ followers_count: row.followers_count,
558
+ avatar_hue: row.avatar_hue,
559
+ avatar_url: row.avatar_url,
560
+ created_at: row.profile_created_at,
561
+ }),
562
+ })),
563
+ };
564
+ }
565
+
566
+ export function queryResource(
567
+ resource: "home" | "mentions" | "dms",
568
+ filters: (TimelineQuery | DmQuery) & { conversationId?: string },
569
+ ): QueryResponse {
570
+ if (resource === "dms") {
571
+ const dmFilters = filters as DmQuery & { conversationId?: string };
572
+ const items = listDmConversations(dmFilters);
573
+ const selectedConversationId = dmFilters.conversationId ?? items[0]?.id;
574
+ return {
575
+ resource,
576
+ items,
577
+ selectedConversation: selectedConversationId
578
+ ? getConversationThread(selectedConversationId)
579
+ : null,
580
+ };
581
+ }
582
+
583
+ return {
584
+ resource,
585
+ items: listTimelineItems({
586
+ resource,
587
+ ...(filters as TimelineQuery),
588
+ }),
589
+ };
590
+ }
591
+
592
+ function refreshDmConversationState(
593
+ db: Database.Database,
594
+ conversationId: string,
595
+ lastMessageAt: string,
596
+ ) {
597
+ db.prepare(
598
+ `
599
+ update dm_conversations
600
+ set last_message_at = ?,
601
+ unread_count = 0,
602
+ needs_reply = 0
603
+ where id = ?
604
+ `,
605
+ ).run(lastMessageAt, conversationId);
606
+ }
607
+
608
+ function getLocalAuthorProfileId(accountId: string) {
609
+ const db = getNativeDb();
610
+ const row = db
611
+ .prepare(
612
+ `
613
+ select p.id
614
+ from accounts a
615
+ join profiles p on p.handle = replace(a.handle, '@', '')
616
+ where a.id = ?
617
+ `,
618
+ )
619
+ .get(accountId) as { id: string } | undefined;
620
+
621
+ return row?.id;
622
+ }
623
+
624
+ export async function createPost(accountId: string, text: string) {
625
+ const db = getNativeDb();
626
+ const authorProfileId = getLocalAuthorProfileId(accountId);
627
+ if (!authorProfileId) {
628
+ throw new Error("No local author profile for account");
629
+ }
630
+
631
+ const now = new Date().toISOString();
632
+ const tweetId = `tweet_${randomUUID()}`;
633
+
634
+ db.prepare(
635
+ `
636
+ insert into tweets (
637
+ id, account_id, author_profile_id, kind, text, created_at,
638
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked
639
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, 0, 0, 0, 0)
640
+ `,
641
+ ).run(tweetId, accountId, authorProfileId, text, now);
642
+
643
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
644
+ tweetId,
645
+ text,
646
+ );
647
+ db.prepare(
648
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
649
+ ).run(randomUUID(), accountId, tweetId, "post", text, now);
650
+
651
+ const transport = await postViaXurl(text);
652
+ return { ok: true, transport, tweetId };
653
+ }
654
+
655
+ export async function createTweetReply(
656
+ accountId: string,
657
+ tweetId: string,
658
+ text: string,
659
+ ) {
660
+ const db = getNativeDb();
661
+ const authorProfileId = getLocalAuthorProfileId(accountId);
662
+ if (!authorProfileId) {
663
+ throw new Error("No local author profile for account");
664
+ }
665
+
666
+ const now = new Date().toISOString();
667
+ db.prepare("update tweets set is_replied = 1 where id = ?").run(tweetId);
668
+
669
+ const replyId = `tweet_${randomUUID()}`;
670
+ db.prepare(
671
+ `
672
+ insert into tweets (
673
+ id, account_id, author_profile_id, kind, text, created_at,
674
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked
675
+ ) values (?, ?, ?, 'home', ?, ?, 1, ?, 0, 0, 0, 0)
676
+ `,
677
+ ).run(replyId, accountId, authorProfileId, text, now, tweetId);
678
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
679
+ replyId,
680
+ text,
681
+ );
682
+
683
+ db.prepare(
684
+ "insert into tweet_actions (id, account_id, tweet_id, kind, body, created_at) values (?, ?, ?, ?, ?, ?)",
685
+ ).run(randomUUID(), accountId, tweetId, "reply", text, now);
686
+
687
+ const transport = await replyViaXurl(tweetId, text);
688
+ return { ok: true, transport, replyId };
689
+ }
690
+
691
+ export async function createDmReply(conversationId: string, text: string) {
692
+ const db = getNativeDb();
693
+ const conversation = getConversationThread(conversationId);
694
+ if (!conversation) {
695
+ throw new Error("Conversation not found");
696
+ }
697
+ const authorProfileId = getLocalAuthorProfileId(
698
+ conversation.conversation.accountId,
699
+ );
700
+ if (!authorProfileId) {
701
+ throw new Error("No local author profile for account");
702
+ }
703
+
704
+ const now = new Date().toISOString();
705
+ const outboundId = `msg_${randomUUID()}`;
706
+
707
+ db.prepare(
708
+ `
709
+ insert into dm_messages (
710
+ id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
711
+ ) values (?, ?, ?, ?, ?, 'outbound', 1, 0)
712
+ `,
713
+ ).run(outboundId, conversationId, authorProfileId, text, now);
714
+ db.prepare("insert into dm_fts (message_id, text) values (?, ?)").run(
715
+ outboundId,
716
+ text,
717
+ );
718
+
719
+ refreshDmConversationState(db, conversationId, now);
720
+ const transport = await dmViaXurl(
721
+ conversation.conversation.participant.handle,
722
+ text,
723
+ );
724
+ return { ok: true, transport, messageId: outboundId };
725
+ }