birdclaw 0.3.0 → 0.4.1
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 +25 -0
- package/README.md +37 -9
- package/package.json +4 -6
- package/src/cli.ts +289 -20
- package/src/lib/archive-import.ts +27 -0
- package/src/lib/backup.ts +271 -33
- package/src/lib/bird-actions.ts +17 -0
- package/src/lib/bird.ts +189 -0
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/db.ts +404 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/link-index.ts +716 -0
- package/src/lib/mention-threads-live.ts +5 -9
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +4 -4
- 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 +4 -28
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +391 -57
- package/src/lib/research.ts +11 -0
- package/src/lib/seed.ts +2 -2
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +7 -7
- package/src/lib/timeline-live.ts +16 -9
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/types.ts +108 -0
- package/src/lib/url-expansion.ts +155 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +140 -12
- package/src/lib/xurl.ts +7 -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,
|
|
@@ -38,6 +39,10 @@ function getInfluenceLabel(score: number) {
|
|
|
38
39
|
|
|
39
40
|
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
40
41
|
const followingCount = Number(row.following_count ?? 0);
|
|
42
|
+
const entities = parseJsonField<Record<string, unknown> | undefined>(
|
|
43
|
+
row.entities_json,
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
41
46
|
return {
|
|
42
47
|
id: String(row.id),
|
|
43
48
|
handle: String(row.handle),
|
|
@@ -48,6 +53,16 @@ function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
|
48
53
|
avatarHue: Number(row.avatar_hue),
|
|
49
54
|
avatarUrl:
|
|
50
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 } : {}),
|
|
51
66
|
createdAt: String(row.created_at),
|
|
52
67
|
};
|
|
53
68
|
}
|
|
@@ -64,6 +79,15 @@ function parseJsonField<T>(value: unknown, fallback: T): T {
|
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
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
|
+
|
|
67
91
|
function enrichEntities(
|
|
68
92
|
entities: TweetEntities,
|
|
69
93
|
profiles: Record<string, ProfileRecord>,
|
|
@@ -211,19 +235,42 @@ function getTimelineQualityReason(
|
|
|
211
235
|
return "keep:long-text";
|
|
212
236
|
}
|
|
213
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);
|
|
266
|
+
}
|
|
267
|
+
|
|
214
268
|
export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
215
269
|
const db = getDb();
|
|
270
|
+
const nativeDb = getNativeDb();
|
|
271
|
+
const homeCount = countTimelineEdges(nativeDb, "home");
|
|
272
|
+
const mentionCount = countTimelineEdges(nativeDb, "mention");
|
|
216
273
|
const counts = await Promise.all([
|
|
217
|
-
db
|
|
218
|
-
.selectFrom("tweets")
|
|
219
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
220
|
-
.where("kind", "=", "home")
|
|
221
|
-
.executeTakeFirstOrThrow(),
|
|
222
|
-
db
|
|
223
|
-
.selectFrom("tweets")
|
|
224
|
-
.select((eb) => eb.fn.countAll().as("count"))
|
|
225
|
-
.where("kind", "=", "mention")
|
|
226
|
-
.executeTakeFirstOrThrow(),
|
|
227
274
|
db
|
|
228
275
|
.selectFrom("dm_conversations")
|
|
229
276
|
.select((eb) => eb.fn.countAll().as("count"))
|
|
@@ -245,13 +292,13 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
245
292
|
|
|
246
293
|
return {
|
|
247
294
|
stats: {
|
|
248
|
-
home:
|
|
249
|
-
mentions:
|
|
250
|
-
dms: Number(counts[
|
|
251
|
-
needsReply: Number(counts[
|
|
252
|
-
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),
|
|
253
300
|
},
|
|
254
|
-
accounts: counts[
|
|
301
|
+
accounts: counts[2].map((row) => ({
|
|
255
302
|
id: row.id,
|
|
256
303
|
name: row.name,
|
|
257
304
|
handle: row.handle,
|
|
@@ -260,8 +307,8 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
|
|
|
260
307
|
isDefault: row.is_default,
|
|
261
308
|
createdAt: row.created_at,
|
|
262
309
|
})) satisfies AccountRecord[],
|
|
263
|
-
archives: counts[
|
|
264
|
-
transport: counts[
|
|
310
|
+
archives: counts[3],
|
|
311
|
+
transport: counts[4],
|
|
265
312
|
};
|
|
266
313
|
}
|
|
267
314
|
|
|
@@ -282,20 +329,88 @@ export function listTimelineItems({
|
|
|
282
329
|
}: TimelineQuery): TimelineItem[] {
|
|
283
330
|
const db = getNativeDb();
|
|
284
331
|
const kind = resource === "mentions" ? "mention" : resource;
|
|
285
|
-
const params: Array<string | number> = [
|
|
332
|
+
const params: Array<string | number> = [];
|
|
286
333
|
const normalizedLowQualityThreshold =
|
|
287
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
|
+
`;
|
|
288
353
|
let join = "";
|
|
289
354
|
let where = "where t.kind = ?";
|
|
290
355
|
let searchSnippetSelect = "";
|
|
291
356
|
|
|
292
357
|
if (likedOnly || bookmarkedOnly) {
|
|
293
|
-
|
|
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
|
+
}
|
|
294
405
|
where = "where 1 = 1";
|
|
406
|
+
} else {
|
|
407
|
+
params.push(kind, kind);
|
|
408
|
+
where = "where e.kind = ?";
|
|
409
|
+
params.push(kind);
|
|
295
410
|
}
|
|
296
411
|
|
|
297
412
|
if (account && account !== "all") {
|
|
298
|
-
where += " and
|
|
413
|
+
where += " and e.account_id = ?";
|
|
299
414
|
params.push(account);
|
|
300
415
|
}
|
|
301
416
|
|
|
@@ -324,20 +439,13 @@ export function listTimelineItems({
|
|
|
324
439
|
params.push(until.trim());
|
|
325
440
|
}
|
|
326
441
|
|
|
327
|
-
|
|
442
|
+
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
443
|
+
if (ftsSearch) {
|
|
328
444
|
join += " join tweets_fts on tweets_fts.tweet_id = t.id ";
|
|
329
445
|
where += " and tweets_fts.text match ?";
|
|
330
446
|
searchSnippetSelect =
|
|
331
447
|
", snippet(tweets_fts, 1, '<mark>', '</mark>', '...', 16) as search_snippet";
|
|
332
|
-
params.push(
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (likedOnly) {
|
|
336
|
-
where += " and t.liked = 1";
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
if (bookmarkedOnly) {
|
|
340
|
-
where += " and t.bookmarked = 1";
|
|
448
|
+
params.push(ftsSearch);
|
|
341
449
|
}
|
|
342
450
|
|
|
343
451
|
params.push(limit);
|
|
@@ -345,18 +453,37 @@ export function listTimelineItems({
|
|
|
345
453
|
const rows = db
|
|
346
454
|
.prepare(
|
|
347
455
|
`
|
|
456
|
+
${timelineEdgesCte}
|
|
348
457
|
select
|
|
349
458
|
t.id,
|
|
350
|
-
|
|
459
|
+
e.account_id,
|
|
351
460
|
a.handle as account_handle,
|
|
352
|
-
|
|
461
|
+
e.kind,
|
|
353
462
|
t.text,
|
|
354
463
|
t.created_at,
|
|
355
464
|
t.is_replied,
|
|
356
465
|
t.like_count,
|
|
357
466
|
t.media_count,
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
360
487
|
t.entities_json,
|
|
361
488
|
t.media_json,
|
|
362
489
|
t.quoted_tweet_id,
|
|
@@ -368,6 +495,10 @@ export function listTimelineItems({
|
|
|
368
495
|
p.following_count,
|
|
369
496
|
p.avatar_hue,
|
|
370
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,
|
|
371
502
|
p.created_at as profile_created_at,
|
|
372
503
|
rt.id as reply_id,
|
|
373
504
|
rt.text as reply_text,
|
|
@@ -398,8 +529,9 @@ export function listTimelineItems({
|
|
|
398
529
|
qp.avatar_url as quoted_avatar_url,
|
|
399
530
|
qp.created_at as quoted_profile_created_at
|
|
400
531
|
${searchSnippetSelect}
|
|
401
|
-
from
|
|
402
|
-
join
|
|
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
|
|
403
535
|
join profiles p on p.id = t.author_profile_id
|
|
404
536
|
left join tweets rt on rt.id = t.reply_to_id
|
|
405
537
|
left join profiles rp on rp.id = rt.author_profile_id
|
|
@@ -497,6 +629,7 @@ export function listTimelineItems({
|
|
|
497
629
|
|
|
498
630
|
export function listDmConversations({
|
|
499
631
|
account,
|
|
632
|
+
conversationIds,
|
|
500
633
|
participant,
|
|
501
634
|
search,
|
|
502
635
|
replyFilter = "all",
|
|
@@ -505,6 +638,7 @@ export function listDmConversations({
|
|
|
505
638
|
minInfluenceScore,
|
|
506
639
|
maxInfluenceScore,
|
|
507
640
|
sort = "recent",
|
|
641
|
+
context = 0,
|
|
508
642
|
limit = 20,
|
|
509
643
|
}: DmQuery): DmConversationItem[] {
|
|
510
644
|
const db = getNativeDb();
|
|
@@ -514,12 +648,18 @@ export function listDmConversations({
|
|
|
514
648
|
let join = "";
|
|
515
649
|
let where = "where 1 = 1";
|
|
516
650
|
let searchSnippetSelect = "";
|
|
651
|
+
const ftsSearch = search?.trim() ? toFtsSearchQuery(search) : "";
|
|
517
652
|
|
|
518
653
|
if (account && account !== "all") {
|
|
519
654
|
where += " and a.id = ?";
|
|
520
655
|
params.push(account);
|
|
521
656
|
}
|
|
522
657
|
|
|
658
|
+
if (conversationIds && conversationIds.length > 0) {
|
|
659
|
+
where += ` and c.id in (${conversationIds.map(() => "?").join(",")})`;
|
|
660
|
+
params.push(...conversationIds);
|
|
661
|
+
}
|
|
662
|
+
|
|
523
663
|
if (participant?.trim()) {
|
|
524
664
|
where += " and (p.handle like ? or p.display_name like ?)";
|
|
525
665
|
params.push(`%${participant.trim()}%`, `%${participant.trim()}%`);
|
|
@@ -541,9 +681,9 @@ export function listDmConversations({
|
|
|
541
681
|
params.push(maxFollowers);
|
|
542
682
|
}
|
|
543
683
|
|
|
544
|
-
if (
|
|
684
|
+
if (ftsSearch) {
|
|
545
685
|
searchSnippetCte = `
|
|
546
|
-
|
|
686
|
+
with ranked_dm_search as materialized (
|
|
547
687
|
select
|
|
548
688
|
latest_search.id,
|
|
549
689
|
latest_search.conversation_id,
|
|
@@ -564,10 +704,10 @@ export function listDmConversations({
|
|
|
564
704
|
where ranked_dm_search.match_rank = 1
|
|
565
705
|
and dm_fts.text match ?
|
|
566
706
|
)
|
|
567
|
-
|
|
707
|
+
`;
|
|
568
708
|
join += " join dm_search on dm_search.conversation_id = c.id ";
|
|
569
709
|
searchSnippetSelect = ", dm_search.search_snippet as search_snippet";
|
|
570
|
-
joinParams.push(
|
|
710
|
+
joinParams.push(ftsSearch, ftsSearch);
|
|
571
711
|
}
|
|
572
712
|
|
|
573
713
|
params.push(limit);
|
|
@@ -592,6 +732,10 @@ export function listDmConversations({
|
|
|
592
732
|
p.following_count,
|
|
593
733
|
p.avatar_hue,
|
|
594
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,
|
|
595
739
|
p.created_at as profile_created_at,
|
|
596
740
|
(
|
|
597
741
|
select text
|
|
@@ -613,9 +757,29 @@ export function listDmConversations({
|
|
|
613
757
|
)
|
|
614
758
|
.all(...joinParams, ...params) as Array<Record<string, unknown>>;
|
|
615
759
|
|
|
616
|
-
const
|
|
760
|
+
const affiliationsByProfile = fetchProfileAffiliations(
|
|
761
|
+
db,
|
|
762
|
+
rows.map((row) => String(row.profile_id)),
|
|
763
|
+
);
|
|
764
|
+
const items: DmConversationItem[] = rows.map((row) => {
|
|
617
765
|
const followersCount = Number(row.followers_count);
|
|
618
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) ?? [];
|
|
619
783
|
return {
|
|
620
784
|
id: String(row.id),
|
|
621
785
|
accountId: String(row.account_id),
|
|
@@ -631,18 +795,13 @@ export function listDmConversations({
|
|
|
631
795
|
influenceScore,
|
|
632
796
|
influenceLabel: getInfluenceLabel(influenceScore),
|
|
633
797
|
participant: {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
avatarUrl:
|
|
642
|
-
typeof row.avatar_url === "string"
|
|
643
|
-
? String(row.avatar_url)
|
|
644
|
-
: undefined,
|
|
645
|
-
createdAt: String(row.profile_created_at),
|
|
798
|
+
...participant,
|
|
799
|
+
...(affiliations.length > 0
|
|
800
|
+
? {
|
|
801
|
+
affiliations,
|
|
802
|
+
primaryAffiliation: affiliations[0],
|
|
803
|
+
}
|
|
804
|
+
: {}),
|
|
646
805
|
},
|
|
647
806
|
};
|
|
648
807
|
});
|
|
@@ -681,7 +840,23 @@ export function listDmConversations({
|
|
|
681
840
|
});
|
|
682
841
|
}
|
|
683
842
|
|
|
684
|
-
|
|
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;
|
|
685
860
|
}
|
|
686
861
|
|
|
687
862
|
export function getConversationThread(
|
|
@@ -749,6 +924,165 @@ export function getConversationThread(
|
|
|
749
924
|
};
|
|
750
925
|
}
|
|
751
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
|
+
|
|
752
1086
|
export function queryResource(
|
|
753
1087
|
resource: "home" | "mentions" | "dms",
|
|
754
1088
|
filters: (TimelineQuery | DmQuery) & { conversationId?: string },
|
|
@@ -779,7 +1113,7 @@ export function queryResource(
|
|
|
779
1113
|
}
|
|
780
1114
|
|
|
781
1115
|
function refreshDmConversationState(
|
|
782
|
-
db: Database
|
|
1116
|
+
db: Database,
|
|
783
1117
|
conversationId: string,
|
|
784
1118
|
lastMessageAt: string,
|
|
785
1119
|
) {
|
package/src/lib/research.ts
CHANGED
|
@@ -593,4 +593,15 @@ export async function runResearchMode(
|
|
|
593
593
|
return report;
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
export const __test__ = {
|
|
597
|
+
parseJsonField,
|
|
598
|
+
normalizeTweetEntities,
|
|
599
|
+
toResearchNode,
|
|
600
|
+
dedupeNodes,
|
|
601
|
+
orderThreadNodes,
|
|
602
|
+
collectExternalLinks,
|
|
603
|
+
collectHandles,
|
|
604
|
+
renderReportMarkdown,
|
|
605
|
+
};
|
|
606
|
+
|
|
596
607
|
export type { ResearchRow };
|
package/src/lib/seed.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type Database from "
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
2
|
|
|
3
3
|
const now = new Date("2026-03-08T12:00:00.000Z");
|
|
4
4
|
|
|
@@ -22,7 +22,7 @@ function svgAvatarDataUrl(label: string, hue: number) {
|
|
|
22
22
|
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function seedDemoData(db: Database
|
|
25
|
+
export function seedDemoData(db: Database) {
|
|
26
26
|
const accountCount = db
|
|
27
27
|
.prepare("select count(*) as count from accounts")
|
|
28
28
|
.get() as {
|