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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/bin/birdclaw.mjs +28 -0
- package/package.json +100 -0
- package/playwright.config.ts +31 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/src/cli-moderation.ts +210 -0
- package/src/cli.ts +450 -0
- package/src/components/AppNav.tsx +49 -0
- package/src/components/AvatarChip.tsx +47 -0
- package/src/components/DmWorkspace.tsx +246 -0
- package/src/components/EmbeddedTweetCard.tsx +44 -0
- package/src/components/InboxCard.tsx +136 -0
- package/src/components/ProfilePreview.tsx +55 -0
- package/src/components/ThemeSlider.tsx +124 -0
- package/src/components/TimelineCard.tsx +118 -0
- package/src/components/TweetMediaGrid.tsx +39 -0
- package/src/components/TweetRichText.tsx +85 -0
- package/src/lib/actions-transport.ts +173 -0
- package/src/lib/archive-finder.ts +128 -0
- package/src/lib/archive-import.ts +736 -0
- package/src/lib/avatar-cache.ts +184 -0
- package/src/lib/bird-actions.ts +200 -0
- package/src/lib/bird.ts +183 -0
- package/src/lib/blocklist.ts +119 -0
- package/src/lib/blocks-write.ts +118 -0
- package/src/lib/blocks.ts +326 -0
- package/src/lib/config.ts +152 -0
- package/src/lib/db.ts +326 -0
- package/src/lib/dms-live.ts +279 -0
- package/src/lib/inbox.ts +210 -0
- package/src/lib/mentions-export.ts +147 -0
- package/src/lib/mentions-live.ts +475 -0
- package/src/lib/moderation-target.ts +171 -0
- package/src/lib/moderation-write.ts +72 -0
- package/src/lib/mutes-write.ts +118 -0
- package/src/lib/mutes.ts +77 -0
- package/src/lib/openai.ts +86 -0
- package/src/lib/present.ts +20 -0
- package/src/lib/profile-hydration.ts +144 -0
- package/src/lib/profile-replies.ts +60 -0
- package/src/lib/queries.ts +725 -0
- package/src/lib/seed.ts +486 -0
- package/src/lib/sync-cache.ts +65 -0
- package/src/lib/theme-transition.ts +117 -0
- package/src/lib/theme.tsx +157 -0
- package/src/lib/tweet-render.ts +115 -0
- package/src/lib/types.ts +316 -0
- package/src/lib/ui.ts +270 -0
- package/src/lib/x-profile.ts +203 -0
- package/src/lib/x-web.ts +168 -0
- package/src/lib/xurl.ts +492 -0
- package/src/routeTree.gen.ts +282 -0
- package/src/router.tsx +20 -0
- package/src/routes/__root.tsx +64 -0
- package/src/routes/api/action.tsx +88 -0
- package/src/routes/api/avatar.tsx +41 -0
- package/src/routes/api/blocks.tsx +32 -0
- package/src/routes/api/inbox.tsx +35 -0
- package/src/routes/api/query.tsx +72 -0
- package/src/routes/api/status.tsx +15 -0
- package/src/routes/blocks.tsx +352 -0
- package/src/routes/dms.tsx +262 -0
- package/src/routes/inbox.tsx +201 -0
- package/src/routes/index.tsx +125 -0
- package/src/routes/mentions.tsx +125 -0
- package/src/styles.css +109 -0
- package/tsconfig.json +30 -0
- 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
|
+
}
|