birdclaw 0.2.1 → 0.4.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 +44 -1
- package/README.md +54 -15
- package/package.json +14 -15
- package/playwright.config.ts +5 -2
- package/src/cli.ts +289 -20
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +34 -2
- package/src/lib/backup.ts +271 -33
- package/src/lib/bird-actions.ts +21 -0
- package/src/lib/bird.ts +332 -17
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +321 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/mention-threads-live.ts +267 -0
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +7 -5
- package/src/lib/moderation-write.ts +3 -3
- package/src/lib/profile-affiliation-hydration.ts +189 -0
- package/src/lib/profile-affiliations.ts +262 -0
- package/src/lib/profile-bio-entities.ts +318 -0
- package/src/lib/profile-history.ts +164 -0
- package/src/lib/profile-hydration.ts +8 -24
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +522 -68
- package/src/lib/research.ts +607 -0
- package/src/lib/seed.ts +10 -4
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +22 -8
- package/src/lib/timeline-live.ts +182 -0
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +103 -1
- package/src/lib/url-expansion.ts +147 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +174 -17
- package/src/lib/xurl.ts +41 -6
package/src/lib/queries.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type Database from "
|
|
2
|
+
import type { Database } from "./sqlite";
|
|
3
3
|
import { findArchives } from "./archive-finder";
|
|
4
4
|
import { getDb, getNativeDb } from "./db";
|
|
5
|
+
import { fetchProfileAffiliations } from "./profile-affiliations";
|
|
5
6
|
import type {
|
|
6
7
|
AccountRecord,
|
|
7
8
|
DmConversationItem,
|
|
@@ -37,15 +38,31 @@ function getInfluenceLabel(score: number) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
41
|
+
const followingCount = Number(row.following_count ?? 0);
|
|
42
|
+
const entities = parseJsonField<Record<string, unknown> | undefined>(
|
|
43
|
+
row.entities_json,
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
40
46
|
return {
|
|
41
47
|
id: String(row.id),
|
|
42
48
|
handle: String(row.handle),
|
|
43
49
|
displayName: String(row.display_name),
|
|
44
50
|
bio: String(row.bio),
|
|
45
51
|
followersCount: Number(row.followers_count),
|
|
52
|
+
...(Number.isFinite(followingCount) ? { followingCount } : {}),
|
|
46
53
|
avatarHue: Number(row.avatar_hue),
|
|
47
54
|
avatarUrl:
|
|
48
55
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
56
|
+
...(typeof row.location === "string" && row.location.length > 0
|
|
57
|
+
? { location: row.location }
|
|
58
|
+
: {}),
|
|
59
|
+
...(typeof row.url === "string" && row.url.length > 0
|
|
60
|
+
? { url: row.url }
|
|
61
|
+
: {}),
|
|
62
|
+
...(typeof row.verified_type === "string" && row.verified_type.length > 0
|
|
63
|
+
? { verifiedType: row.verified_type }
|
|
64
|
+
: {}),
|
|
65
|
+
...(entities ? { entities } : {}),
|
|
49
66
|
createdAt: String(row.created_at),
|
|
50
67
|
};
|
|
51
68
|
}
|
|
@@ -62,6 +79,15 @@ function parseJsonField<T>(value: unknown, fallback: T): T {
|
|
|
62
79
|
}
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
function toFtsSearchQuery(value: string) {
|
|
83
|
+
const terms = value.match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
84
|
+
return terms
|
|
85
|
+
.map((term) => term.trim())
|
|
86
|
+
.filter((term) => term.length > 0)
|
|
87
|
+
.map((term) => `"${term.replaceAll('"', '""')}"`)
|
|
88
|
+
.join(" ");
|
|
89
|
+
}
|
|
90
|
+
|
|
65
91
|
function enrichEntities(
|
|
66
92
|
entities: TweetEntities,
|
|
67
93
|
profiles: Record<string, ProfileRecord>,
|
|
@@ -95,6 +121,7 @@ function buildEmbeddedTweet(
|
|
|
95
121
|
display_name: row[`${prefix}display_name`],
|
|
96
122
|
bio: row[`${prefix}bio`],
|
|
97
123
|
followers_count: row[`${prefix}followers_count`],
|
|
124
|
+
following_count: row[`${prefix}following_count`],
|
|
98
125
|
avatar_hue: row[`${prefix}avatar_hue`],
|
|
99
126
|
avatar_url: row[`${prefix}avatar_url`],
|
|
100
127
|
created_at: row[`${prefix}profile_created_at`],
|
|
@@ -125,16 +152,28 @@ function buildReplyClause(replyFilter: ReplyFilter) {
|
|
|
125
152
|
return "";
|
|
126
153
|
}
|
|
127
154
|
|
|
128
|
-
function
|
|
155
|
+
function normalizeLowQualityThreshold(threshold: number | undefined) {
|
|
156
|
+
const value = threshold ?? 50;
|
|
157
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
158
|
+
throw new Error("lowQualityThreshold must be a non-negative integer");
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function buildTimelineQualityClause(
|
|
164
|
+
qualityFilter: TimelineQualityFilter,
|
|
165
|
+
lowQualityThreshold: number,
|
|
166
|
+
) {
|
|
129
167
|
if (qualityFilter === "all") {
|
|
130
|
-
return "";
|
|
168
|
+
return { sql: "", params: [] };
|
|
131
169
|
}
|
|
132
170
|
|
|
133
|
-
return
|
|
171
|
+
return {
|
|
172
|
+
sql: `
|
|
134
173
|
and not (
|
|
135
174
|
t.text like 'RT @%'
|
|
136
175
|
or (
|
|
137
|
-
t.like_count <
|
|
176
|
+
t.like_count < ?
|
|
138
177
|
and (
|
|
139
178
|
(
|
|
140
179
|
length(trim(replace(t.text, 'https://t.co/', ''))) < 16
|
|
@@ -152,22 +191,86 @@ function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
|
|
|
152
191
|
)
|
|
153
192
|
)
|
|
154
193
|
)
|
|
155
|
-
|
|
194
|
+
`,
|
|
195
|
+
params: [lowQualityThreshold],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getTimelineQualityReason(
|
|
200
|
+
row: Record<string, unknown>,
|
|
201
|
+
lowQualityThreshold: number,
|
|
202
|
+
) {
|
|
203
|
+
const text = String(row.text);
|
|
204
|
+
const trimmed = text.trim();
|
|
205
|
+
const strippedShortUrlText = text.replaceAll("https://t.co/", "").trim();
|
|
206
|
+
const likeCount = Number(row.like_count);
|
|
207
|
+
const mediaCount = Number(row.media_count);
|
|
208
|
+
|
|
209
|
+
if (text.startsWith("RT @")) {
|
|
210
|
+
return "drop:rt";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (likeCount < lowQualityThreshold) {
|
|
214
|
+
if (text.startsWith("@") && trimmed.length < 60) {
|
|
215
|
+
return "drop:short-reply";
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
text.includes("https://t.co/") &&
|
|
219
|
+
mediaCount === 0 &&
|
|
220
|
+
strippedShortUrlText.length < 45
|
|
221
|
+
) {
|
|
222
|
+
return "drop:short-link-only";
|
|
223
|
+
}
|
|
224
|
+
if (strippedShortUrlText.length < 16 && mediaCount === 0) {
|
|
225
|
+
return "drop:short-text";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (mediaCount > 0) {
|
|
230
|
+
return "keep:has-media";
|
|
231
|
+
}
|
|
232
|
+
if (likeCount >= lowQualityThreshold) {
|
|
233
|
+
return "keep:high-likes";
|
|
234
|
+
}
|
|
235
|
+
return "keep:long-text";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function countTimelineEdges(db: Database, kind: "home" | "mention") {
|
|
239
|
+
const row = db
|
|
240
|
+
.prepare(
|
|
241
|
+
`
|
|
242
|
+
with timeline_edges as (
|
|
243
|
+
select account_id, tweet_id, kind
|
|
244
|
+
from tweet_account_edges
|
|
245
|
+
where kind = ?
|
|
246
|
+
union all
|
|
247
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
248
|
+
from tweets legacy
|
|
249
|
+
where legacy.kind = ?
|
|
250
|
+
and not exists (
|
|
251
|
+
select 1
|
|
252
|
+
from tweet_account_edges edge
|
|
253
|
+
where edge.account_id = legacy.account_id
|
|
254
|
+
and edge.tweet_id = legacy.id
|
|
255
|
+
and edge.kind = legacy.kind
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
select count(*) as count
|
|
259
|
+
from timeline_edges e
|
|
260
|
+
join tweets t on t.id = e.tweet_id
|
|
261
|
+
where e.kind = ?
|
|
262
|
+
`,
|
|
263
|
+
)
|
|
264
|
+
.get(kind, kind, kind) as { count: number | bigint } | undefined;
|
|
265
|
+
return Number(row?.count ?? 0);
|
|
156
266
|
}
|
|
157
267
|
|
|
158
268
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
159
269
|
const db = getDb();
|
|
270
|
+
const nativeDb = getNativeDb();
|
|
271
|
+
const homeCount = countTimelineEdges(nativeDb, "home");
|
|
272
|
+
const mentionCount = countTimelineEdges(nativeDb, "mention");
|
|
160
273
|
const counts = await Promise.all([
|
|
161
|
-
db
|
|
162
|
-
.selectFrom("tweets")
|
|
163
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
164
|
-
.where("kind", "=", "home")
|
|
165
|
-
.executeTakeFirstOrThrow(),
|
|
166
|
-
db
|
|
167
|
-
.selectFrom("tweets")
|
|
168
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
169
|
-
.where("kind", "=", "mention")
|
|
170
|
-
.executeTakeFirstOrThrow(),
|
|
171
274
|
db
|
|
172
275
|
.selectFrom("dm_conversations")
|
|
173
276
|
.select((eb) => eb.fn.countAll().as("count"))
|
|
@@ -189,13 +292,13 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
189
292
|
|
|
190
293
|
return {
|
|
191
294
|
stats: {
|
|
192
|
-
home:
|
|
193
|
-
mentions:
|
|
194
|
-
dms: Number(counts[
|
|
195
|
-
needsReply: Number(counts[
|
|
196
|
-
inbox:
|
|
295
|
+
home: homeCount,
|
|
296
|
+
mentions: mentionCount,
|
|
297
|
+
dms: Number(counts[0].count),
|
|
298
|
+
needsReply: Number(counts[1].count),
|
|
299
|
+
inbox: mentionCount + Number(counts[1].count),
|
|
197
300
|
},
|
|
198
|
-
accounts: counts[
|
|
301
|
+
accounts: counts[2].map((row) => ({
|
|
199
302
|
id: row.id,
|
|
200
303
|
name: row.name,
|
|
201
304
|
handle: row.handle,
|
|
@@ -204,8 +307,8 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
204
307
|
isDefault: row.is_default,
|
|
205
308
|
createdAt: row.created_at,
|
|
206
309
|
})) satisfies AccountRecord[],
|
|
207
|
-
archives: counts[
|
|
208
|
-
transport: counts[
|
|
310
|
+
archives: counts[3],
|
|
311
|
+
transport: counts[4],
|
|
209
312
|
};
|
|
210
313
|
}
|
|
211
314
|
|
|
@@ -218,23 +321,96 @@ export function listTimelineItems({
|
|
|
218
321
|
until,
|
|
219
322
|
includeReplies = true,
|
|
220
323
|
qualityFilter = "all",
|
|
324
|
+
lowQualityThreshold,
|
|
325
|
+
includeQualityReason = false,
|
|
221
326
|
likedOnly = false,
|
|
222
327
|
bookmarkedOnly = false,
|
|
223
328
|
limit = 18,
|
|
224
329
|
}: TimelineQuery): TimelineItem[] {
|
|
225
330
|
const db = getNativeDb();
|
|
226
331
|
const kind = resource === "mentions" ? "mention" : resource;
|
|
227
|
-
const params: Array<string | number> = [
|
|
332
|
+
const params: Array<string | number> = [];
|
|
333
|
+
const normalizedLowQualityThreshold =
|
|
334
|
+
normalizeLowQualityThreshold(lowQualityThreshold);
|
|
335
|
+
let timelineEdgesCte = `
|
|
336
|
+
with timeline_edges as (
|
|
337
|
+
select account_id, tweet_id, kind
|
|
338
|
+
from tweet_account_edges
|
|
339
|
+
where kind = ?
|
|
340
|
+
union all
|
|
341
|
+
select legacy.account_id, legacy.id as tweet_id, legacy.kind
|
|
342
|
+
from tweets legacy
|
|
343
|
+
where legacy.kind = ?
|
|
344
|
+
and not exists (
|
|
345
|
+
select 1
|
|
346
|
+
from tweet_account_edges edge
|
|
347
|
+
where edge.account_id = legacy.account_id
|
|
348
|
+
and edge.tweet_id = legacy.id
|
|
349
|
+
and edge.kind = legacy.kind
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
`;
|
|
228
353
|
let join = "";
|
|
229
354
|
let where = "where t.kind = ?";
|
|
355
|
+
let searchSnippetSelect = "";
|
|
230
356
|
|
|
231
357
|
if (likedOnly || bookmarkedOnly) {
|
|
232
|
-
|
|
358
|
+
if (likedOnly && bookmarkedOnly) {
|
|
359
|
+
timelineEdgesCte = `
|
|
360
|
+
with timeline_edges as (
|
|
361
|
+
select likes.account_id, likes.tweet_id, 'home' as kind
|
|
362
|
+
from tweet_collections likes
|
|
363
|
+
join tweet_collections bookmarks
|
|
364
|
+
on bookmarks.account_id = likes.account_id
|
|
365
|
+
and bookmarks.tweet_id = likes.tweet_id
|
|
366
|
+
and bookmarks.kind = 'bookmarks'
|
|
367
|
+
where likes.kind = 'likes'
|
|
368
|
+
union all
|
|
369
|
+
select legacy.account_id, legacy.id as tweet_id, 'home' as kind
|
|
370
|
+
from tweets legacy
|
|
371
|
+
where legacy.liked = 1
|
|
372
|
+
and legacy.bookmarked = 1
|
|
373
|
+
and not exists (
|
|
374
|
+
select 1
|
|
375
|
+
from tweet_collections collection
|
|
376
|
+
where collection.account_id = legacy.account_id
|
|
377
|
+
and collection.tweet_id = legacy.id
|
|
378
|
+
and collection.kind in ('likes', 'bookmarks')
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
`;
|
|
382
|
+
} else {
|
|
383
|
+
const collectionKind = likedOnly ? "likes" : "bookmarks";
|
|
384
|
+
const legacyColumn = likedOnly ? "liked" : "bookmarked";
|
|
385
|
+
timelineEdgesCte = `
|
|
386
|
+
with timeline_edges as (
|
|
387
|
+
select account_id, tweet_id, 'home' as kind
|
|
388
|
+
from tweet_collections
|
|
389
|
+
where kind = ?
|
|
390
|
+
union all
|
|
391
|
+
select legacy.account_id, legacy.id as tweet_id, 'home' as kind
|
|
392
|
+
from tweets legacy
|
|
393
|
+
where legacy.${legacyColumn} = 1
|
|
394
|
+
and not exists (
|
|
395
|
+
select 1
|
|
396
|
+
from tweet_collections collection
|
|
397
|
+
where collection.account_id = legacy.account_id
|
|
398
|
+
and collection.tweet_id = legacy.id
|
|
399
|
+
and collection.kind = ?
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
`;
|
|
403
|
+
params.push(collectionKind, collectionKind);
|
|
404
|
+
}
|
|
233
405
|
where = "where 1 = 1";
|
|
406
|
+
} else {
|
|
407
|
+
params.push(kind, kind);
|
|
408
|
+
where = "where e.kind = ?";
|
|
409
|
+
params.push(kind);
|
|
234
410
|
}
|
|
235
411
|
|
|
236
412
|
if (account && account !== "all") {
|
|
237
|
-
where += " and
|
|
413
|
+
where += " and e.account_id = ?";
|
|
238
414
|
params.push(account);
|
|
239
415
|
}
|
|
240
416
|
|
|
@@ -242,7 +418,12 @@ export function listTimelineItems({
|
|
|
242
418
|
"is_replied",
|
|
243
419
|
"t.is_replied",
|
|
244
420
|
);
|
|
245
|
-
|
|
421
|
+
const qualityClause = buildTimelineQualityClause(
|
|
422
|
+
qualityFilter,
|
|
423
|
+
normalizedLowQualityThreshold,
|
|
424
|
+
);
|
|
425
|
+
where += qualityClause.sql;
|
|
426
|
+
params.push(...qualityClause.params);
|
|
246
427
|
|
|
247
428
|
if (!includeReplies) {
|
|
248
429
|
where += " and t.text not like '@%'";
|
|
@@ -258,18 +439,13 @@ export function listTimelineItems({
|
|
|
258
439
|
params.push(until.trim());
|
|
259
440
|
}
|
|
260
441
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
where += " and t.liked = 1";
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (bookmarkedOnly) {
|
|
272
|
-
where += " and t.bookmarked = 1";
|
|
442
|
+
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
443
|
+
if (ftsSearch) {
|
|
444
|
+
join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
|
|
445
|
+
where += " and tweets_fts.text match ?";
|
|
446
|
+
searchSnippetSelect =
|
|
447
|
+
", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
|
|
448
|
+
params.push(ftsSearch);
|
|
273
449
|
}
|
|
274
450
|
|
|
275
451
|
params.push(limit);
|
|
@@ -277,18 +453,37 @@ export function listTimelineItems({
|
|
|
277
453
|
const rows = db
|
|
278
454
|
.prepare(
|
|
279
455
|
`
|
|
456
|
+
${timelineEdgesCte}
|
|
280
457
|
select
|
|
281
458
|
t.id,
|
|
282
|
-
|
|
459
|
+
e.account_id,
|
|
283
460
|
a.handle as account_handle,
|
|
284
|
-
|
|
461
|
+
e.kind,
|
|
285
462
|
t.text,
|
|
286
463
|
t.created_at,
|
|
287
464
|
t.is_replied,
|
|
288
465
|
t.like_count,
|
|
289
466
|
t.media_count,
|
|
290
|
-
|
|
291
|
-
|
|
467
|
+
case
|
|
468
|
+
when exists (
|
|
469
|
+
select 1 from tweet_collections collection
|
|
470
|
+
where collection.account_id = e.account_id
|
|
471
|
+
and collection.tweet_id = t.id
|
|
472
|
+
and collection.kind = 'bookmarks'
|
|
473
|
+
) then 1
|
|
474
|
+
when t.account_id = e.account_id and t.bookmarked = 1 then 1
|
|
475
|
+
else 0
|
|
476
|
+
end as bookmarked,
|
|
477
|
+
case
|
|
478
|
+
when exists (
|
|
479
|
+
select 1 from tweet_collections collection
|
|
480
|
+
where collection.account_id = e.account_id
|
|
481
|
+
and collection.tweet_id = t.id
|
|
482
|
+
and collection.kind = 'likes'
|
|
483
|
+
) then 1
|
|
484
|
+
when t.account_id = e.account_id and t.liked = 1 then 1
|
|
485
|
+
else 0
|
|
486
|
+
end as liked,
|
|
292
487
|
t.entities_json,
|
|
293
488
|
t.media_json,
|
|
294
489
|
t.quoted_tweet_id,
|
|
@@ -297,8 +492,13 @@ export function listTimelineItems({
|
|
|
297
492
|
p.display_name,
|
|
298
493
|
p.bio,
|
|
299
494
|
p.followers_count,
|
|
495
|
+
p.following_count,
|
|
300
496
|
p.avatar_hue,
|
|
301
497
|
p.avatar_url,
|
|
498
|
+
p.location as profile_location,
|
|
499
|
+
p.url as profile_url,
|
|
500
|
+
p.verified_type as profile_verified_type,
|
|
501
|
+
p.entities_json as profile_entities_json,
|
|
302
502
|
p.created_at as profile_created_at,
|
|
303
503
|
rt.id as reply_id,
|
|
304
504
|
rt.text as reply_text,
|
|
@@ -310,6 +510,7 @@ export function listTimelineItems({
|
|
|
310
510
|
rp.display_name as reply_display_name,
|
|
311
511
|
rp.bio as reply_bio,
|
|
312
512
|
rp.followers_count as reply_followers_count,
|
|
513
|
+
rp.following_count as reply_following_count,
|
|
313
514
|
rp.avatar_hue as reply_avatar_hue,
|
|
314
515
|
rp.avatar_url as reply_avatar_url,
|
|
315
516
|
rp.created_at as reply_profile_created_at,
|
|
@@ -323,11 +524,14 @@ export function listTimelineItems({
|
|
|
323
524
|
qp.display_name as quoted_display_name,
|
|
324
525
|
qp.bio as quoted_bio,
|
|
325
526
|
qp.followers_count as quoted_followers_count,
|
|
527
|
+
qp.following_count as quoted_following_count,
|
|
326
528
|
qp.avatar_hue as quoted_avatar_hue,
|
|
327
529
|
qp.avatar_url as quoted_avatar_url,
|
|
328
530
|
qp.created_at as quoted_profile_created_at
|
|
329
|
-
|
|
330
|
-
|
|
531
|
+
${searchSnippetSelect}
|
|
532
|
+
from timeline_edges e
|
|
533
|
+
join tweets t on t.id = e.tweet_id
|
|
534
|
+
join accounts a on a.id = e.account_id
|
|
331
535
|
join profiles p on p.id = t.author_profile_id
|
|
332
536
|
left join tweets rt on rt.id = t.reply_to_id
|
|
333
537
|
left join profiles rp on rp.id = rt.author_profile_id
|
|
@@ -348,6 +552,7 @@ export function listTimelineItems({
|
|
|
348
552
|
displayName: String(row.display_name),
|
|
349
553
|
bio: String(row.bio),
|
|
350
554
|
followersCount: Number(row.followers_count),
|
|
555
|
+
followingCount: Number(row.following_count ?? 0),
|
|
351
556
|
avatarHue: Number(row.avatar_hue),
|
|
352
557
|
avatarUrl:
|
|
353
558
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
@@ -365,6 +570,7 @@ export function listTimelineItems({
|
|
|
365
570
|
display_name: row.reply_display_name,
|
|
366
571
|
bio: row.reply_bio,
|
|
367
572
|
followers_count: row.reply_followers_count,
|
|
573
|
+
following_count: row.reply_following_count,
|
|
368
574
|
avatar_hue: row.reply_avatar_hue,
|
|
369
575
|
avatar_url: row.reply_avatar_url,
|
|
370
576
|
created_at: row.reply_profile_created_at,
|
|
@@ -379,6 +585,7 @@ export function listTimelineItems({
|
|
|
379
585
|
display_name: row.quoted_display_name,
|
|
380
586
|
bio: row.quoted_bio,
|
|
381
587
|
followers_count: row.quoted_followers_count,
|
|
588
|
+
following_count: row.quoted_following_count,
|
|
382
589
|
avatar_hue: row.quoted_avatar_hue,
|
|
383
590
|
avatar_url: row.quoted_avatar_url,
|
|
384
591
|
created_at: row.quoted_profile_created_at,
|
|
@@ -387,12 +594,15 @@ export function listTimelineItems({
|
|
|
387
594
|
: {}),
|
|
388
595
|
},
|
|
389
596
|
);
|
|
390
|
-
|
|
597
|
+
const item = {
|
|
391
598
|
id: String(row.id),
|
|
392
599
|
accountId: String(row.account_id),
|
|
393
600
|
accountHandle: String(row.account_handle),
|
|
394
601
|
kind: row.kind as TimelineItem["kind"],
|
|
395
602
|
text: String(row.text),
|
|
603
|
+
...(typeof row.search_snippet === "string"
|
|
604
|
+
? { searchSnippet: row.search_snippet }
|
|
605
|
+
: {}),
|
|
396
606
|
createdAt: String(row.created_at),
|
|
397
607
|
isReplied: Boolean(row.is_replied),
|
|
398
608
|
likeCount: Number(row.like_count),
|
|
@@ -405,11 +615,21 @@ export function listTimelineItems({
|
|
|
405
615
|
replyToTweet: buildEmbeddedTweet(row, "reply_"),
|
|
406
616
|
quotedTweet: buildEmbeddedTweet(row, "quoted_"),
|
|
407
617
|
};
|
|
618
|
+
return includeQualityReason
|
|
619
|
+
? {
|
|
620
|
+
...item,
|
|
621
|
+
qualityReason: getTimelineQualityReason(
|
|
622
|
+
row,
|
|
623
|
+
normalizedLowQualityThreshold,
|
|
624
|
+
),
|
|
625
|
+
}
|
|
626
|
+
: item;
|
|
408
627
|
});
|
|
409
628
|
}
|
|
410
629
|
|
|
411
630
|
export function listDmConversations({
|
|
412
631
|
account,
|
|
632
|
+
conversationIds,
|
|
413
633
|
participant,
|
|
414
634
|
search,
|
|
415
635
|
replyFilter = "all",
|
|
@@ -418,18 +638,28 @@ export function listDmConversations({
|
|
|
418
638
|
minInfluenceScore,
|
|
419
639
|
maxInfluenceScore,
|
|
420
640
|
sort = "recent",
|
|
641
|
+
context = 0,
|
|
421
642
|
limit = 20,
|
|
422
643
|
}: DmQuery): DmConversationItem[] {
|
|
423
644
|
const db = getNativeDb();
|
|
424
645
|
const params: Array<string | number> = [];
|
|
646
|
+
const joinParams: Array<string | number> = [];
|
|
647
|
+
let searchSnippetCte = "";
|
|
425
648
|
let join = "";
|
|
426
649
|
let where = "where 1 = 1";
|
|
650
|
+
let searchSnippetSelect = "";
|
|
651
|
+
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
427
652
|
|
|
428
653
|
if (account && account !== "all") {
|
|
429
654
|
where += " and a.id = ?";
|
|
430
655
|
params.push(account);
|
|
431
656
|
}
|
|
432
657
|
|
|
658
|
+
if (conversationIds && conversationIds.length > 0) {
|
|
659
|
+
where += ` and c.id in (${conversationIds.map(() => "?").join(",")})`;
|
|
660
|
+
params.push(...conversationIds);
|
|
661
|
+
}
|
|
662
|
+
|
|
433
663
|
if (participant?.trim()) {
|
|
434
664
|
where += " and (p.handle like ? or p.display_name like ?)";
|
|
435
665
|
params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
|
|
@@ -451,12 +681,33 @@ export function listDmConversations({
|
|
|
451
681
|
params.push(maxFollowers);
|
|
452
682
|
}
|
|
453
683
|
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
684
|
+
if (ftsSearch) {
|
|
685
|
+
searchSnippetCte = `
|
|
686
|
+
with ranked_dm_search as materialized (
|
|
687
|
+
select
|
|
688
|
+
latest_search.id,
|
|
689
|
+
latest_search.conversation_id,
|
|
690
|
+
row_number() over (
|
|
691
|
+
partition by latest_search.conversation_id
|
|
692
|
+
order by latest_search.created_at desc, latest_search.id desc
|
|
693
|
+
) as match_rank
|
|
694
|
+
from dm_messages latest_search
|
|
695
|
+
join dm_fts on dm_fts.message_id = latest_search.id
|
|
696
|
+
where dm_fts.text match ?
|
|
697
|
+
),
|
|
698
|
+
dm_search as materialized (
|
|
699
|
+
select
|
|
700
|
+
ranked_dm_search.conversation_id,
|
|
701
|
+
snippet(dm_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet
|
|
702
|
+
from ranked_dm_search
|
|
703
|
+
join dm_fts on dm_fts.message_id = ranked_dm_search.id
|
|
704
|
+
where ranked_dm_search.match_rank = 1
|
|
705
|
+
and dm_fts.text match ?
|
|
706
|
+
)
|
|
707
|
+
`;
|
|
708
|
+
join += " join dm_search on dm_search.conversation_id = c.id ";
|
|
709
|
+
searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
|
|
710
|
+
joinParams.push(ftsSearch, ftsSearch);
|
|
460
711
|
}
|
|
461
712
|
|
|
462
713
|
params.push(limit);
|
|
@@ -464,6 +715,7 @@ export function listDmConversations({
|
|
|
464
715
|
const rows = db
|
|
465
716
|
.prepare(
|
|
466
717
|
`
|
|
718
|
+
${searchSnippetCte}
|
|
467
719
|
select
|
|
468
720
|
c.id,
|
|
469
721
|
c.account_id,
|
|
@@ -477,8 +729,13 @@ export function listDmConversations({
|
|
|
477
729
|
p.display_name,
|
|
478
730
|
p.bio,
|
|
479
731
|
p.followers_count,
|
|
732
|
+
p.following_count,
|
|
480
733
|
p.avatar_hue,
|
|
481
734
|
p.avatar_url,
|
|
735
|
+
p.location as profile_location,
|
|
736
|
+
p.url as profile_url,
|
|
737
|
+
p.verified_type as profile_verified_type,
|
|
738
|
+
p.entities_json as profile_entities_json,
|
|
482
739
|
p.created_at as profile_created_at,
|
|
483
740
|
(
|
|
484
741
|
select text
|
|
@@ -487,6 +744,7 @@ export function listDmConversations({
|
|
|
487
744
|
order by latest_message.created_at desc
|
|
488
745
|
limit 1
|
|
489
746
|
) as last_message_preview
|
|
747
|
+
${searchSnippetSelect}
|
|
490
748
|
from dm_conversations c
|
|
491
749
|
join accounts a on a.id = c.account_id
|
|
492
750
|
join profiles p on p.id = c.participant_profile_id
|
|
@@ -497,16 +755,39 @@ export function listDmConversations({
|
|
|
497
755
|
limit ?
|
|
498
756
|
`,
|
|
499
757
|
)
|
|
500
|
-
.all(...params) as Array<Record<string, unknown>>;
|
|
758
|
+
.all(...joinParams, ...params) as Array<Record<string, unknown>>;
|
|
501
759
|
|
|
502
|
-
const
|
|
760
|
+
const affiliationsByProfile = fetchProfileAffiliations(
|
|
761
|
+
db,
|
|
762
|
+
rows.map((row) => String(row.profile_id)),
|
|
763
|
+
);
|
|
764
|
+
const items: DmConversationItem[] = rows.map((row) => {
|
|
503
765
|
const followersCount = Number(row.followers_count);
|
|
504
766
|
const influenceScore = getInfluenceScore(followersCount);
|
|
767
|
+
const participant = toProfile({
|
|
768
|
+
id: row.profile_id,
|
|
769
|
+
handle: row.handle,
|
|
770
|
+
display_name: row.display_name,
|
|
771
|
+
bio: row.bio,
|
|
772
|
+
followers_count: row.followers_count,
|
|
773
|
+
following_count: row.following_count,
|
|
774
|
+
avatar_hue: row.avatar_hue,
|
|
775
|
+
avatar_url: row.avatar_url,
|
|
776
|
+
location: row.profile_location,
|
|
777
|
+
url: row.profile_url,
|
|
778
|
+
verified_type: row.profile_verified_type,
|
|
779
|
+
entities_json: row.profile_entities_json,
|
|
780
|
+
created_at: row.profile_created_at,
|
|
781
|
+
});
|
|
782
|
+
const affiliations = affiliationsByProfile.get(participant.id) ?? [];
|
|
505
783
|
return {
|
|
506
784
|
id: String(row.id),
|
|
507
785
|
accountId: String(row.account_id),
|
|
508
786
|
accountHandle: String(row.account_handle),
|
|
509
787
|
title: String(row.title),
|
|
788
|
+
...(typeof row.search_snippet === "string"
|
|
789
|
+
? { searchSnippet: row.search_snippet }
|
|
790
|
+
: {}),
|
|
510
791
|
lastMessageAt: String(row.last_message_at),
|
|
511
792
|
lastMessagePreview: String(row.last_message_preview ?? ""),
|
|
512
793
|
unreadCount: Number(row.unread_count),
|
|
@@ -514,17 +795,13 @@ export function listDmConversations({
|
|
|
514
795
|
influenceScore,
|
|
515
796
|
influenceLabel: getInfluenceLabel(influenceScore),
|
|
516
797
|
participant: {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
typeof row.avatar_url === "string"
|
|
525
|
-
? String(row.avatar_url)
|
|
526
|
-
: undefined,
|
|
527
|
-
createdAt: String(row.profile_created_at),
|
|
798
|
+
...participant,
|
|
799
|
+
...(affiliations.length > 0
|
|
800
|
+
? {
|
|
801
|
+
affiliations,
|
|
802
|
+
primaryAffiliation: affiliations[0],
|
|
803
|
+
}
|
|
804
|
+
: {}),
|
|
528
805
|
},
|
|
529
806
|
};
|
|
530
807
|
});
|
|
@@ -563,7 +840,23 @@ export function listDmConversations({
|
|
|
563
840
|
});
|
|
564
841
|
}
|
|
565
842
|
|
|
566
|
-
|
|
843
|
+
const limited = filtered.slice(0, limit);
|
|
844
|
+
const normalizedContext = normalizeDmContext(context);
|
|
845
|
+
if (ftsSearch && normalizedContext > 0 && limited.length > 0) {
|
|
846
|
+
const matches = getDmSearchMatches({
|
|
847
|
+
search: ftsSearch,
|
|
848
|
+
conversationIds: limited.map((item) => item.id),
|
|
849
|
+
context: normalizedContext,
|
|
850
|
+
});
|
|
851
|
+
for (const item of limited) {
|
|
852
|
+
const itemMatches = matches.get(item.id);
|
|
853
|
+
if (itemMatches && itemMatches.length > 0) {
|
|
854
|
+
item.matches = itemMatches;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return limited;
|
|
567
860
|
}
|
|
568
861
|
|
|
569
862
|
export function getConversationThread(
|
|
@@ -594,6 +887,7 @@ export function getConversationThread(
|
|
|
594
887
|
p.display_name,
|
|
595
888
|
p.bio,
|
|
596
889
|
p.followers_count,
|
|
890
|
+
p.following_count,
|
|
597
891
|
p.avatar_hue,
|
|
598
892
|
p.avatar_url,
|
|
599
893
|
p.created_at as profile_created_at
|
|
@@ -621,6 +915,7 @@ export function getConversationThread(
|
|
|
621
915
|
display_name: row.display_name,
|
|
622
916
|
bio: row.bio,
|
|
623
917
|
followers_count: row.followers_count,
|
|
918
|
+
following_count: row.following_count,
|
|
624
919
|
avatar_hue: row.avatar_hue,
|
|
625
920
|
avatar_url: row.avatar_url,
|
|
626
921
|
created_at: row.profile_created_at,
|
|
@@ -629,6 +924,165 @@ export function getConversationThread(
|
|
|
629
924
|
};
|
|
630
925
|
}
|
|
631
926
|
|
|
927
|
+
function normalizeDmContext(value: number | undefined) {
|
|
928
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
929
|
+
return 0;
|
|
930
|
+
}
|
|
931
|
+
return Math.max(0, Math.min(20, Math.trunc(value)));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function mapDmMessageRow(row: Record<string, unknown>): DmMessageItem {
|
|
935
|
+
return {
|
|
936
|
+
id: String(row.id),
|
|
937
|
+
conversationId: String(row.conversation_id),
|
|
938
|
+
text: String(row.text),
|
|
939
|
+
createdAt: String(row.created_at),
|
|
940
|
+
direction: row.direction as DmMessageItem["direction"],
|
|
941
|
+
isReplied: Boolean(row.is_replied),
|
|
942
|
+
mediaCount: Number(row.media_count),
|
|
943
|
+
sender: toProfile({
|
|
944
|
+
id: row.profile_id,
|
|
945
|
+
handle: row.handle,
|
|
946
|
+
display_name: row.display_name,
|
|
947
|
+
bio: row.bio,
|
|
948
|
+
followers_count: row.followers_count,
|
|
949
|
+
following_count: row.following_count,
|
|
950
|
+
avatar_hue: row.avatar_hue,
|
|
951
|
+
avatar_url: row.avatar_url,
|
|
952
|
+
created_at: row.profile_created_at,
|
|
953
|
+
}),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function selectDmMessageSql(where: string, orderBy: string) {
|
|
958
|
+
return `
|
|
959
|
+
select
|
|
960
|
+
m.id,
|
|
961
|
+
m.conversation_id,
|
|
962
|
+
m.text,
|
|
963
|
+
m.created_at,
|
|
964
|
+
m.direction,
|
|
965
|
+
m.is_replied,
|
|
966
|
+
m.media_count,
|
|
967
|
+
p.id as profile_id,
|
|
968
|
+
p.handle,
|
|
969
|
+
p.display_name,
|
|
970
|
+
p.bio,
|
|
971
|
+
p.followers_count,
|
|
972
|
+
p.following_count,
|
|
973
|
+
p.avatar_hue,
|
|
974
|
+
p.avatar_url,
|
|
975
|
+
p.created_at as profile_created_at
|
|
976
|
+
from dm_messages m
|
|
977
|
+
join profiles p on p.id = m.sender_profile_id
|
|
978
|
+
${where}
|
|
979
|
+
${orderBy}
|
|
980
|
+
`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function getDmSearchMatches({
|
|
984
|
+
search,
|
|
985
|
+
conversationIds,
|
|
986
|
+
context,
|
|
987
|
+
}: {
|
|
988
|
+
search: string;
|
|
989
|
+
conversationIds: string[];
|
|
990
|
+
context: number;
|
|
991
|
+
}) {
|
|
992
|
+
const db = getNativeDb();
|
|
993
|
+
if (search.length === 0) {
|
|
994
|
+
return new Map<string, DmConversationItem["matches"]>();
|
|
995
|
+
}
|
|
996
|
+
const conversationPlaceholders = conversationIds.map(() => "?").join(", ");
|
|
997
|
+
const matchRows = db
|
|
998
|
+
.prepare(
|
|
999
|
+
`
|
|
1000
|
+
with ranked_matches as (
|
|
1001
|
+
select
|
|
1002
|
+
m.id,
|
|
1003
|
+
m.conversation_id,
|
|
1004
|
+
m.text,
|
|
1005
|
+
m.created_at,
|
|
1006
|
+
m.direction,
|
|
1007
|
+
m.is_replied,
|
|
1008
|
+
m.media_count,
|
|
1009
|
+
p.id as profile_id,
|
|
1010
|
+
p.handle,
|
|
1011
|
+
p.display_name,
|
|
1012
|
+
p.bio,
|
|
1013
|
+
p.followers_count,
|
|
1014
|
+
p.following_count,
|
|
1015
|
+
p.avatar_hue,
|
|
1016
|
+
p.avatar_url,
|
|
1017
|
+
p.created_at as profile_created_at,
|
|
1018
|
+
row_number() over (
|
|
1019
|
+
partition by m.conversation_id
|
|
1020
|
+
order by m.created_at desc, m.id desc
|
|
1021
|
+
) as match_rank
|
|
1022
|
+
from dm_messages m
|
|
1023
|
+
join dm_fts on dm_fts.message_id = m.id
|
|
1024
|
+
join profiles p on p.id = m.sender_profile_id
|
|
1025
|
+
where dm_fts.text match ?
|
|
1026
|
+
and m.conversation_id in (${conversationPlaceholders})
|
|
1027
|
+
)
|
|
1028
|
+
select *
|
|
1029
|
+
from ranked_matches
|
|
1030
|
+
where match_rank <= 3
|
|
1031
|
+
order by created_at desc, id desc
|
|
1032
|
+
`,
|
|
1033
|
+
)
|
|
1034
|
+
.all(search, ...conversationIds) as Array<Record<string, unknown>>;
|
|
1035
|
+
|
|
1036
|
+
const beforeStatement = db.prepare(
|
|
1037
|
+
selectDmMessageSql(
|
|
1038
|
+
`
|
|
1039
|
+
where m.conversation_id = ?
|
|
1040
|
+
and (m.created_at < ? or (m.created_at = ? and m.id < ?))
|
|
1041
|
+
`,
|
|
1042
|
+
"order by m.created_at desc, m.id desc limit ?",
|
|
1043
|
+
),
|
|
1044
|
+
);
|
|
1045
|
+
const afterStatement = db.prepare(
|
|
1046
|
+
selectDmMessageSql(
|
|
1047
|
+
`
|
|
1048
|
+
where m.conversation_id = ?
|
|
1049
|
+
and (m.created_at > ? or (m.created_at = ? and m.id > ?))
|
|
1050
|
+
`,
|
|
1051
|
+
"order by m.created_at asc, m.id asc limit ?",
|
|
1052
|
+
),
|
|
1053
|
+
);
|
|
1054
|
+
const grouped = new Map<string, DmConversationItem["matches"]>();
|
|
1055
|
+
|
|
1056
|
+
for (const row of matchRows) {
|
|
1057
|
+
const message = mapDmMessageRow(row);
|
|
1058
|
+
const before = (
|
|
1059
|
+
beforeStatement.all(
|
|
1060
|
+
message.conversationId,
|
|
1061
|
+
message.createdAt,
|
|
1062
|
+
message.createdAt,
|
|
1063
|
+
message.id,
|
|
1064
|
+
context,
|
|
1065
|
+
) as Array<Record<string, unknown>>
|
|
1066
|
+
)
|
|
1067
|
+
.map(mapDmMessageRow)
|
|
1068
|
+
.reverse();
|
|
1069
|
+
const after = (
|
|
1070
|
+
afterStatement.all(
|
|
1071
|
+
message.conversationId,
|
|
1072
|
+
message.createdAt,
|
|
1073
|
+
message.createdAt,
|
|
1074
|
+
message.id,
|
|
1075
|
+
context,
|
|
1076
|
+
) as Array<Record<string, unknown>>
|
|
1077
|
+
).map(mapDmMessageRow);
|
|
1078
|
+
const matches = grouped.get(message.conversationId) ?? [];
|
|
1079
|
+
matches.push({ message, before, after });
|
|
1080
|
+
grouped.set(message.conversationId, matches);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return grouped;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
632
1086
|
export function queryResource(
|
|
633
1087
|
resource: "home" | "mentions" | "dms",
|
|
634
1088
|
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
@@ -659,7 +1113,7 @@ export function queryResource(
|
|
|
659
1113
|
}
|
|
660
1114
|
|
|
661
1115
|
function refreshDmConversationState(
|
|
662
|
-
db: Database
|
|
1116
|
+
db: Database,
|
|
663
1117
|
conversationId: string,
|
|
664
1118
|
lastMessageAt: string,
|
|
665
1119
|
) {
|