birdclaw 0.3.0 → 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 +19 -0
- package/README.md +37 -9
- package/package.json +4 -6
- package/src/cli.ts +176 -20
- package/src/lib/archive-import.ts +25 -0
- package/src/lib/backup.ts +269 -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 +313 -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 +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 +74 -0
- package/src/lib/url-expansion.ts +147 -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/bird.ts
CHANGED
|
@@ -73,6 +73,31 @@ export interface BirdDmsResponse {
|
|
|
73
73
|
events: BirdDmEvent[];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
interface BirdUserOverviewPayload {
|
|
77
|
+
user?: {
|
|
78
|
+
id?: string;
|
|
79
|
+
username?: string;
|
|
80
|
+
name?: string;
|
|
81
|
+
description?: string;
|
|
82
|
+
location?: string;
|
|
83
|
+
url?: string;
|
|
84
|
+
verified?: boolean;
|
|
85
|
+
verifiedType?: string;
|
|
86
|
+
verified_type?: string;
|
|
87
|
+
followersCount?: number;
|
|
88
|
+
followingCount?: number;
|
|
89
|
+
profileImageUrl?: string;
|
|
90
|
+
createdAt?: string;
|
|
91
|
+
entities?: Record<string, unknown>;
|
|
92
|
+
affiliation?: Record<string, unknown>;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BirdProfilesPayload {
|
|
97
|
+
users?: NonNullable<BirdUserOverviewPayload["user"]>[];
|
|
98
|
+
errors?: Array<{ target?: string; error?: string }>;
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
function toIsoTimestamp(value: string) {
|
|
77
102
|
const parsed = new Date(value);
|
|
78
103
|
if (Number.isNaN(parsed.getTime())) {
|
|
@@ -161,6 +186,18 @@ function formatBirdCommandError(error: unknown, birdCommand: string) {
|
|
|
161
186
|
return error;
|
|
162
187
|
}
|
|
163
188
|
|
|
189
|
+
function isUnsupportedBirdOptionError(error: unknown, option: string) {
|
|
190
|
+
if (!error || typeof error !== "object") {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const text = [
|
|
194
|
+
error instanceof Error ? error.message : "",
|
|
195
|
+
"stderr" in error && typeof error.stderr === "string" ? error.stderr : "",
|
|
196
|
+
"stdout" in error && typeof error.stdout === "string" ? error.stdout : "",
|
|
197
|
+
].join("\n");
|
|
198
|
+
return text.includes(option) && /unknown option|error:/i.test(text);
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
async function runBirdJsonCommand(args: string[], timeoutMs?: number) {
|
|
165
202
|
const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
|
|
166
203
|
const stdoutPath = join(tempDir, "stdout.json");
|
|
@@ -446,3 +483,155 @@ export async function listDirectMessagesViaBird({
|
|
|
446
483
|
|
|
447
484
|
return payload as BirdDmsResponse;
|
|
448
485
|
}
|
|
486
|
+
|
|
487
|
+
export async function lookupProfileViaBird(
|
|
488
|
+
usernameOrId: string,
|
|
489
|
+
): Promise<XurlMentionUser | null> {
|
|
490
|
+
const target = usernameOrId.trim().replace(/^@/, "");
|
|
491
|
+
if (!target) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let stdout: string;
|
|
496
|
+
try {
|
|
497
|
+
stdout = await runBirdJsonCommand([
|
|
498
|
+
"user",
|
|
499
|
+
target,
|
|
500
|
+
"--json",
|
|
501
|
+
"--profile-only",
|
|
502
|
+
]);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (!isUnsupportedBirdOptionError(error, "--profile-only")) {
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
stdout = await runBirdJsonCommand([
|
|
508
|
+
"user",
|
|
509
|
+
target,
|
|
510
|
+
"--json",
|
|
511
|
+
"--count",
|
|
512
|
+
"1",
|
|
513
|
+
]);
|
|
514
|
+
}
|
|
515
|
+
const payload = parseBirdJson(stdout) as BirdUserOverviewPayload;
|
|
516
|
+
return toXurlMentionUser(payload.user);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function toXurlMentionUser(
|
|
520
|
+
user: BirdUserOverviewPayload["user"],
|
|
521
|
+
): XurlMentionUser | null {
|
|
522
|
+
if (!user?.id || !user.username) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
id: String(user.id),
|
|
528
|
+
username: String(user.username).replace(/^@/, ""),
|
|
529
|
+
name: String(user.name ?? user.username),
|
|
530
|
+
description:
|
|
531
|
+
typeof user.description === "string" ? user.description : undefined,
|
|
532
|
+
location: typeof user.location === "string" ? user.location : undefined,
|
|
533
|
+
url: typeof user.url === "string" ? user.url : undefined,
|
|
534
|
+
verified: typeof user.verified === "boolean" ? user.verified : undefined,
|
|
535
|
+
verified_type:
|
|
536
|
+
typeof user.verifiedType === "string"
|
|
537
|
+
? user.verifiedType
|
|
538
|
+
: typeof user.verified_type === "string"
|
|
539
|
+
? user.verified_type
|
|
540
|
+
: undefined,
|
|
541
|
+
profile_image_url:
|
|
542
|
+
typeof user.profileImageUrl === "string"
|
|
543
|
+
? user.profileImageUrl
|
|
544
|
+
: undefined,
|
|
545
|
+
entities:
|
|
546
|
+
user.entities && typeof user.entities === "object"
|
|
547
|
+
? user.entities
|
|
548
|
+
: undefined,
|
|
549
|
+
affiliation:
|
|
550
|
+
user.affiliation && typeof user.affiliation === "object"
|
|
551
|
+
? user.affiliation
|
|
552
|
+
: undefined,
|
|
553
|
+
created_at: typeof user.createdAt === "string" ? user.createdAt : undefined,
|
|
554
|
+
public_metrics: {
|
|
555
|
+
followers_count: Number(user.followersCount ?? 0),
|
|
556
|
+
following_count: Number(user.followingCount ?? 0),
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function lookupProfilesViaBird(
|
|
562
|
+
usernameOrIds: string[],
|
|
563
|
+
): Promise<
|
|
564
|
+
Array<{ target: string; user: XurlMentionUser | null; error?: string }>
|
|
565
|
+
> {
|
|
566
|
+
const targets = Array.from(
|
|
567
|
+
new Set(
|
|
568
|
+
usernameOrIds
|
|
569
|
+
.map((target) => target.trim().replace(/^@/, ""))
|
|
570
|
+
.filter((target) => target.length > 0),
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
if (targets.length === 0) {
|
|
574
|
+
return [];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const stdout = await runBirdJsonCommand(["profiles", ...targets, "--json"]);
|
|
579
|
+
const payload = parseBirdJson(stdout) as BirdProfilesPayload;
|
|
580
|
+
const users = (payload.users ?? [])
|
|
581
|
+
.map(toXurlMentionUser)
|
|
582
|
+
.filter((user): user is XurlMentionUser => Boolean(user));
|
|
583
|
+
const byTarget = new Map<string, XurlMentionUser>();
|
|
584
|
+
for (const user of users) {
|
|
585
|
+
byTarget.set(String(user.id), user);
|
|
586
|
+
byTarget.set(user.username.toLowerCase(), user);
|
|
587
|
+
}
|
|
588
|
+
const errors = new Map(
|
|
589
|
+
(payload.errors ?? []).map((item) => [
|
|
590
|
+
String(item.target ?? "")
|
|
591
|
+
.replace(/^@/, "")
|
|
592
|
+
.toLowerCase(),
|
|
593
|
+
item.error ?? "Unknown error",
|
|
594
|
+
]),
|
|
595
|
+
);
|
|
596
|
+
return targets.map((target) => ({
|
|
597
|
+
target,
|
|
598
|
+
user: byTarget.get(target.toLowerCase()) ?? null,
|
|
599
|
+
...(errors.has(target.toLowerCase())
|
|
600
|
+
? { error: errors.get(target.toLowerCase()) }
|
|
601
|
+
: {}),
|
|
602
|
+
}));
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (!isUnsupportedBirdOptionError(error, "profiles")) {
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
return Promise.all(
|
|
608
|
+
targets.map(async (target) => {
|
|
609
|
+
try {
|
|
610
|
+
return { target, user: await lookupProfileViaBird(target) };
|
|
611
|
+
} catch (lookupError) {
|
|
612
|
+
return {
|
|
613
|
+
target,
|
|
614
|
+
user: null,
|
|
615
|
+
error:
|
|
616
|
+
lookupError instanceof Error
|
|
617
|
+
? lookupError.message
|
|
618
|
+
: String(lookupError),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export const __test__ = {
|
|
627
|
+
toIsoTimestamp,
|
|
628
|
+
escapeJsonStringControlChars,
|
|
629
|
+
parseBirdJson,
|
|
630
|
+
formatBirdCommandError,
|
|
631
|
+
isUnsupportedBirdOptionError,
|
|
632
|
+
getBirdTweetItems,
|
|
633
|
+
getBirdTweetItem,
|
|
634
|
+
toMediaEntities,
|
|
635
|
+
toReferencedTweets,
|
|
636
|
+
normalizeBirdTweets,
|
|
637
|
+
};
|
package/src/lib/blocks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type Database from "
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
2
|
import { getNativeDb } from "./db";
|
|
3
3
|
import {
|
|
4
4
|
getAccountHandle,
|
|
@@ -16,7 +16,7 @@ function remoteBlockSyncDisabled() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function upsertRemoteBlock(
|
|
19
|
-
db: Database
|
|
19
|
+
db: Database,
|
|
20
20
|
accountId: string,
|
|
21
21
|
profileId: string,
|
|
22
22
|
blockedAt: string,
|
|
@@ -33,7 +33,7 @@ function upsertRemoteBlock(
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function pruneRemoteBlocks(
|
|
36
|
-
db: Database
|
|
36
|
+
db: Database,
|
|
37
37
|
accountId: string,
|
|
38
38
|
profileIds: string[],
|
|
39
39
|
) {
|
|
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import type Database from "
|
|
6
|
+
import type { Database } from "./sqlite";
|
|
7
7
|
import { maybeAutoSyncBackup, type BackupAutoUpdateResult } from "./backup";
|
|
8
8
|
import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
|
|
9
9
|
import { getNativeDb } from "./db";
|
|
@@ -31,7 +31,7 @@ export interface BookmarkSyncJobOptions {
|
|
|
31
31
|
cacheTtlMs?: number;
|
|
32
32
|
logPath?: string;
|
|
33
33
|
lockPath?: string;
|
|
34
|
-
db?: Database
|
|
34
|
+
db?: Database;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export interface BookmarkSyncAuditEntry {
|
|
@@ -117,7 +117,7 @@ export function getDefaultBookmarkSyncLockPath() {
|
|
|
117
117
|
return path.join(getBirdclawPaths().rootDir, "locks", "bookmarks-sync.lock");
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
function countBookmarks(db: Database
|
|
120
|
+
function countBookmarks(db: Database) {
|
|
121
121
|
const row = db
|
|
122
122
|
.prepare("select count(*) as count from tweet_collections where kind = ?")
|
|
123
123
|
.get("bookmarks") as { count: number };
|
package/src/lib/db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import NativeSqliteDatabase, { type Database } from "./sqlite";
|
|
2
2
|
import { Kysely, SqliteDialect } from "kysely";
|
|
3
3
|
import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
|
|
4
4
|
import { seedDemoData } from "./seed";
|
|
@@ -22,9 +22,69 @@ export interface ProfilesTable {
|
|
|
22
22
|
following_count: number;
|
|
23
23
|
avatar_hue: number;
|
|
24
24
|
avatar_url: string | null;
|
|
25
|
+
location: string | null;
|
|
26
|
+
url: string | null;
|
|
27
|
+
verified_type: string | null;
|
|
28
|
+
entities_json: string;
|
|
29
|
+
raw_json: string;
|
|
25
30
|
created_at: string;
|
|
26
31
|
}
|
|
27
32
|
|
|
33
|
+
export interface ProfileAffiliationsTable {
|
|
34
|
+
subject_profile_id: string;
|
|
35
|
+
organization_profile_id: string;
|
|
36
|
+
organization_name: string | null;
|
|
37
|
+
organization_handle: string | null;
|
|
38
|
+
badge_url: string | null;
|
|
39
|
+
url: string | null;
|
|
40
|
+
label: string | null;
|
|
41
|
+
source: string;
|
|
42
|
+
is_active: number;
|
|
43
|
+
first_seen_at: string;
|
|
44
|
+
last_seen_at: string;
|
|
45
|
+
raw_json: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ProfileSnapshotsTable {
|
|
50
|
+
profile_id: string;
|
|
51
|
+
snapshot_hash: string;
|
|
52
|
+
observed_at: string;
|
|
53
|
+
last_seen_at: string;
|
|
54
|
+
source: string;
|
|
55
|
+
handle: string;
|
|
56
|
+
display_name: string;
|
|
57
|
+
bio: string;
|
|
58
|
+
location: string | null;
|
|
59
|
+
url: string | null;
|
|
60
|
+
verified_type: string | null;
|
|
61
|
+
followers_count: number;
|
|
62
|
+
following_count: number;
|
|
63
|
+
affiliations_json: string;
|
|
64
|
+
raw_json: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ProfileBioEntitiesTable {
|
|
68
|
+
profile_id: string;
|
|
69
|
+
kind: string;
|
|
70
|
+
value: string;
|
|
71
|
+
source: string;
|
|
72
|
+
is_active: number;
|
|
73
|
+
first_seen_at: string;
|
|
74
|
+
last_seen_at: string;
|
|
75
|
+
raw_json: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface IdentitySearchIndexTable {
|
|
79
|
+
profile_id: string;
|
|
80
|
+
kind: string;
|
|
81
|
+
value: string;
|
|
82
|
+
normalized_value: string;
|
|
83
|
+
source: string;
|
|
84
|
+
weight: number;
|
|
85
|
+
updated_at: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
28
88
|
export interface TweetsTable {
|
|
29
89
|
id: string;
|
|
30
90
|
account_id: string;
|
|
@@ -53,6 +113,18 @@ export interface TweetCollectionsTable {
|
|
|
53
113
|
updated_at: string;
|
|
54
114
|
}
|
|
55
115
|
|
|
116
|
+
export interface TweetAccountEdgesTable {
|
|
117
|
+
account_id: string;
|
|
118
|
+
tweet_id: string;
|
|
119
|
+
kind: "home" | "mention";
|
|
120
|
+
first_seen_at: string;
|
|
121
|
+
last_seen_at: string;
|
|
122
|
+
seen_count: number;
|
|
123
|
+
source: string;
|
|
124
|
+
raw_json: string;
|
|
125
|
+
updated_at: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
56
128
|
export interface DmConversationsTable {
|
|
57
129
|
id: string;
|
|
58
130
|
account_id: string;
|
|
@@ -116,8 +188,13 @@ export interface SyncCacheTable {
|
|
|
116
188
|
export interface BirdclawDatabase {
|
|
117
189
|
accounts: AccountsTable;
|
|
118
190
|
profiles: ProfilesTable;
|
|
191
|
+
profile_affiliations: ProfileAffiliationsTable;
|
|
192
|
+
profile_snapshots: ProfileSnapshotsTable;
|
|
193
|
+
profile_bio_entities: ProfileBioEntitiesTable;
|
|
194
|
+
identity_search_index: IdentitySearchIndexTable;
|
|
119
195
|
tweets: TweetsTable;
|
|
120
196
|
tweet_collections: TweetCollectionsTable;
|
|
197
|
+
tweet_account_edges: TweetAccountEdgesTable;
|
|
121
198
|
dm_conversations: DmConversationsTable;
|
|
122
199
|
dm_messages: DmMessagesTable;
|
|
123
200
|
tweet_actions: TweetActionsTable;
|
|
@@ -127,7 +204,7 @@ export interface BirdclawDatabase {
|
|
|
127
204
|
sync_cache: SyncCacheTable;
|
|
128
205
|
}
|
|
129
206
|
|
|
130
|
-
let nativeDb:
|
|
207
|
+
let nativeDb: Database | undefined;
|
|
131
208
|
let kyselyDb: Kysely<BirdclawDatabase> | undefined;
|
|
132
209
|
|
|
133
210
|
export interface InitDatabaseOptions {
|
|
@@ -158,9 +235,73 @@ const BASE_SCHEMA_SQL = `
|
|
|
158
235
|
following_count integer not null default 0,
|
|
159
236
|
avatar_hue integer not null default 0,
|
|
160
237
|
avatar_url text,
|
|
238
|
+
location text,
|
|
239
|
+
url text,
|
|
240
|
+
verified_type text,
|
|
241
|
+
entities_json text not null default '{}',
|
|
242
|
+
raw_json text not null default '{}',
|
|
161
243
|
created_at text not null
|
|
162
244
|
);
|
|
163
245
|
|
|
246
|
+
create table if not exists profile_affiliations (
|
|
247
|
+
subject_profile_id text not null,
|
|
248
|
+
organization_profile_id text not null,
|
|
249
|
+
organization_name text,
|
|
250
|
+
organization_handle text,
|
|
251
|
+
badge_url text,
|
|
252
|
+
url text,
|
|
253
|
+
label text,
|
|
254
|
+
source text not null,
|
|
255
|
+
is_active integer not null default 1,
|
|
256
|
+
first_seen_at text not null,
|
|
257
|
+
last_seen_at text not null,
|
|
258
|
+
raw_json text not null default '{}',
|
|
259
|
+
updated_at text not null,
|
|
260
|
+
primary key (subject_profile_id, organization_profile_id)
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
create table if not exists profile_snapshots (
|
|
264
|
+
profile_id text not null,
|
|
265
|
+
snapshot_hash text not null,
|
|
266
|
+
observed_at text not null,
|
|
267
|
+
last_seen_at text not null,
|
|
268
|
+
source text not null,
|
|
269
|
+
handle text not null,
|
|
270
|
+
display_name text not null,
|
|
271
|
+
bio text not null,
|
|
272
|
+
location text,
|
|
273
|
+
url text,
|
|
274
|
+
verified_type text,
|
|
275
|
+
followers_count integer not null default 0,
|
|
276
|
+
following_count integer not null default 0,
|
|
277
|
+
affiliations_json text not null default '[]',
|
|
278
|
+
raw_json text not null default '{}',
|
|
279
|
+
primary key (profile_id, snapshot_hash)
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
create table if not exists profile_bio_entities (
|
|
283
|
+
profile_id text not null,
|
|
284
|
+
kind text not null,
|
|
285
|
+
value text not null,
|
|
286
|
+
source text not null,
|
|
287
|
+
is_active integer not null default 1,
|
|
288
|
+
first_seen_at text not null,
|
|
289
|
+
last_seen_at text not null,
|
|
290
|
+
raw_json text not null default '{}',
|
|
291
|
+
primary key (profile_id, kind, value)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
create table if not exists identity_search_index (
|
|
295
|
+
profile_id text not null,
|
|
296
|
+
kind text not null,
|
|
297
|
+
value text not null,
|
|
298
|
+
normalized_value text not null,
|
|
299
|
+
source text not null,
|
|
300
|
+
weight integer not null,
|
|
301
|
+
updated_at text not null,
|
|
302
|
+
primary key (profile_id, kind, value, source)
|
|
303
|
+
);
|
|
304
|
+
|
|
164
305
|
create table if not exists tweets (
|
|
165
306
|
id text primary key,
|
|
166
307
|
account_id text not null,
|
|
@@ -190,6 +331,19 @@ const BASE_SCHEMA_SQL = `
|
|
|
190
331
|
primary key (account_id, tweet_id, kind)
|
|
191
332
|
);
|
|
192
333
|
|
|
334
|
+
create table if not exists tweet_account_edges (
|
|
335
|
+
account_id text not null,
|
|
336
|
+
tweet_id text not null,
|
|
337
|
+
kind text not null,
|
|
338
|
+
first_seen_at text not null,
|
|
339
|
+
last_seen_at text not null,
|
|
340
|
+
seen_count integer not null default 1,
|
|
341
|
+
source text not null,
|
|
342
|
+
raw_json text not null default '{}',
|
|
343
|
+
updated_at text not null,
|
|
344
|
+
primary key (account_id, tweet_id, kind)
|
|
345
|
+
);
|
|
346
|
+
|
|
193
347
|
create table if not exists dm_conversations (
|
|
194
348
|
id text primary key,
|
|
195
349
|
account_id text not null,
|
|
@@ -270,27 +424,33 @@ const INDEX_SQL = `
|
|
|
270
424
|
create index if not exists idx_tweets_quoted on tweets(quoted_tweet_id);
|
|
271
425
|
create index if not exists idx_tweet_collections_kind_account on tweet_collections(kind, account_id, collected_at desc, tweet_id);
|
|
272
426
|
create index if not exists idx_tweet_collections_tweet on tweet_collections(tweet_id);
|
|
427
|
+
create index if not exists idx_tweet_account_edges_kind_account on tweet_account_edges(kind, account_id, last_seen_at desc, tweet_id);
|
|
428
|
+
create index if not exists idx_tweet_account_edges_tweet on tweet_account_edges(tweet_id);
|
|
273
429
|
create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
|
|
274
430
|
create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
|
|
275
431
|
create index if not exists idx_profiles_followers on profiles(followers_count desc);
|
|
276
432
|
create index if not exists idx_profiles_following on profiles(following_count desc);
|
|
433
|
+
create index if not exists idx_profile_affiliations_subject on profile_affiliations(subject_profile_id, is_active, last_seen_at desc);
|
|
434
|
+
create index if not exists idx_profile_affiliations_org on profile_affiliations(organization_profile_id, is_active, last_seen_at desc);
|
|
435
|
+
create index if not exists idx_profile_snapshots_profile on profile_snapshots(profile_id, last_seen_at desc);
|
|
436
|
+
create index if not exists idx_profile_bio_entities_profile on profile_bio_entities(profile_id, is_active, last_seen_at desc);
|
|
437
|
+
create index if not exists idx_profile_bio_entities_value on profile_bio_entities(kind, value, is_active);
|
|
438
|
+
create index if not exists idx_identity_search_index_profile on identity_search_index(profile_id);
|
|
439
|
+
create index if not exists idx_identity_search_index_value on identity_search_index(normalized_value, kind, weight desc);
|
|
277
440
|
create index if not exists idx_blocks_account_created on blocks(account_id, created_at desc);
|
|
278
441
|
create index if not exists idx_mutes_account_created on mutes(account_id, created_at desc);
|
|
279
442
|
create index if not exists idx_ai_scores_updated on ai_scores(updated_at desc);
|
|
280
443
|
create index if not exists idx_sync_cache_updated on sync_cache(updated_at desc);
|
|
281
444
|
`;
|
|
282
445
|
|
|
283
|
-
function getColumnNames(
|
|
284
|
-
db: BetterSqlite3.Database,
|
|
285
|
-
tableName: string,
|
|
286
|
-
): Set<string> {
|
|
446
|
+
function getColumnNames(db: Database, tableName: string): Set<string> {
|
|
287
447
|
const rows = db.prepare(`pragma table_info(${tableName})`).all() as Array<{
|
|
288
448
|
name: string;
|
|
289
449
|
}>;
|
|
290
450
|
return new Set(rows.map((row) => row.name));
|
|
291
451
|
}
|
|
292
452
|
|
|
293
|
-
function ensureTweetMetadataColumns(db:
|
|
453
|
+
function ensureTweetMetadataColumns(db: Database) {
|
|
294
454
|
const columnNames = getColumnNames(db, "tweets");
|
|
295
455
|
if (!columnNames.has("entities_json")) {
|
|
296
456
|
db.exec(
|
|
@@ -307,7 +467,7 @@ function ensureTweetMetadataColumns(db: BetterSqlite3.Database) {
|
|
|
307
467
|
}
|
|
308
468
|
}
|
|
309
469
|
|
|
310
|
-
function ensureProfileAvatarColumns(db:
|
|
470
|
+
function ensureProfileAvatarColumns(db: Database) {
|
|
311
471
|
const columnNames = getColumnNames(db, "profiles");
|
|
312
472
|
if (!columnNames.has("following_count")) {
|
|
313
473
|
db.exec(
|
|
@@ -317,16 +477,35 @@ function ensureProfileAvatarColumns(db: BetterSqlite3.Database) {
|
|
|
317
477
|
if (!columnNames.has("avatar_url")) {
|
|
318
478
|
db.exec("alter table profiles add column avatar_url text");
|
|
319
479
|
}
|
|
480
|
+
if (!columnNames.has("location")) {
|
|
481
|
+
db.exec("alter table profiles add column location text");
|
|
482
|
+
}
|
|
483
|
+
if (!columnNames.has("url")) {
|
|
484
|
+
db.exec("alter table profiles add column url text");
|
|
485
|
+
}
|
|
486
|
+
if (!columnNames.has("verified_type")) {
|
|
487
|
+
db.exec("alter table profiles add column verified_type text");
|
|
488
|
+
}
|
|
489
|
+
if (!columnNames.has("entities_json")) {
|
|
490
|
+
db.exec(
|
|
491
|
+
"alter table profiles add column entities_json text not null default '{}'",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (!columnNames.has("raw_json")) {
|
|
495
|
+
db.exec(
|
|
496
|
+
"alter table profiles add column raw_json text not null default '{}'",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
320
499
|
}
|
|
321
500
|
|
|
322
|
-
function ensureAccountExternalUserIdColumn(db:
|
|
501
|
+
function ensureAccountExternalUserIdColumn(db: Database) {
|
|
323
502
|
const columnNames = getColumnNames(db, "accounts");
|
|
324
503
|
if (!columnNames.has("external_user_id")) {
|
|
325
504
|
db.exec("alter table accounts add column external_user_id text");
|
|
326
505
|
}
|
|
327
506
|
}
|
|
328
507
|
|
|
329
|
-
function ensureTweetCollectionsTable(db:
|
|
508
|
+
function ensureTweetCollectionsTable(db: Database) {
|
|
330
509
|
db.exec(`
|
|
331
510
|
create table if not exists tweet_collections (
|
|
332
511
|
account_id text not null,
|
|
@@ -341,7 +520,99 @@ function ensureTweetCollectionsTable(db: BetterSqlite3.Database) {
|
|
|
341
520
|
`);
|
|
342
521
|
}
|
|
343
522
|
|
|
344
|
-
function
|
|
523
|
+
function ensureTweetAccountEdgesTable(db: Database) {
|
|
524
|
+
db.exec(`
|
|
525
|
+
create table if not exists tweet_account_edges (
|
|
526
|
+
account_id text not null,
|
|
527
|
+
tweet_id text not null,
|
|
528
|
+
kind text not null,
|
|
529
|
+
first_seen_at text not null,
|
|
530
|
+
last_seen_at text not null,
|
|
531
|
+
seen_count integer not null default 1,
|
|
532
|
+
source text not null,
|
|
533
|
+
raw_json text not null default '{}',
|
|
534
|
+
updated_at text not null,
|
|
535
|
+
primary key (account_id, tweet_id, kind)
|
|
536
|
+
);
|
|
537
|
+
`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function ensureProfileAffiliationsTable(db: Database) {
|
|
541
|
+
db.exec(`
|
|
542
|
+
create table if not exists profile_affiliations (
|
|
543
|
+
subject_profile_id text not null,
|
|
544
|
+
organization_profile_id text not null,
|
|
545
|
+
organization_name text,
|
|
546
|
+
organization_handle text,
|
|
547
|
+
badge_url text,
|
|
548
|
+
url text,
|
|
549
|
+
label text,
|
|
550
|
+
source text not null,
|
|
551
|
+
is_active integer not null default 1,
|
|
552
|
+
first_seen_at text not null,
|
|
553
|
+
last_seen_at text not null,
|
|
554
|
+
raw_json text not null default '{}',
|
|
555
|
+
updated_at text not null,
|
|
556
|
+
primary key (subject_profile_id, organization_profile_id)
|
|
557
|
+
);
|
|
558
|
+
`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function ensureProfileSnapshotsTable(db: Database) {
|
|
562
|
+
db.exec(`
|
|
563
|
+
create table if not exists profile_snapshots (
|
|
564
|
+
profile_id text not null,
|
|
565
|
+
snapshot_hash text not null,
|
|
566
|
+
observed_at text not null,
|
|
567
|
+
last_seen_at text not null,
|
|
568
|
+
source text not null,
|
|
569
|
+
handle text not null,
|
|
570
|
+
display_name text not null,
|
|
571
|
+
bio text not null,
|
|
572
|
+
location text,
|
|
573
|
+
url text,
|
|
574
|
+
verified_type text,
|
|
575
|
+
followers_count integer not null default 0,
|
|
576
|
+
following_count integer not null default 0,
|
|
577
|
+
affiliations_json text not null default '[]',
|
|
578
|
+
raw_json text not null default '{}',
|
|
579
|
+
primary key (profile_id, snapshot_hash)
|
|
580
|
+
);
|
|
581
|
+
`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function ensureProfileBioEntitiesTable(db: Database) {
|
|
585
|
+
db.exec(`
|
|
586
|
+
create table if not exists profile_bio_entities (
|
|
587
|
+
profile_id text not null,
|
|
588
|
+
kind text not null,
|
|
589
|
+
value text not null,
|
|
590
|
+
source text not null,
|
|
591
|
+
is_active integer not null default 1,
|
|
592
|
+
first_seen_at text not null,
|
|
593
|
+
last_seen_at text not null,
|
|
594
|
+
raw_json text not null default '{}',
|
|
595
|
+
primary key (profile_id, kind, value)
|
|
596
|
+
);
|
|
597
|
+
`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function ensureIdentitySearchIndexTable(db: Database) {
|
|
601
|
+
db.exec(`
|
|
602
|
+
create table if not exists identity_search_index (
|
|
603
|
+
profile_id text not null,
|
|
604
|
+
kind text not null,
|
|
605
|
+
value text not null,
|
|
606
|
+
normalized_value text not null,
|
|
607
|
+
source text not null,
|
|
608
|
+
weight integer not null,
|
|
609
|
+
updated_at text not null,
|
|
610
|
+
primary key (profile_id, kind, value, source)
|
|
611
|
+
);
|
|
612
|
+
`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function backfillTweetCollections(db: Database) {
|
|
345
616
|
const now = new Date().toISOString();
|
|
346
617
|
const insert = db.prepare(`
|
|
347
618
|
insert or ignore into tweet_collections (
|
|
@@ -362,7 +633,29 @@ function backfillTweetCollections(db: BetterSqlite3.Database) {
|
|
|
362
633
|
})();
|
|
363
634
|
}
|
|
364
635
|
|
|
365
|
-
function
|
|
636
|
+
function backfillTweetAccountEdges(db: Database) {
|
|
637
|
+
const now = new Date().toISOString();
|
|
638
|
+
db.prepare(`
|
|
639
|
+
insert or ignore into tweet_account_edges (
|
|
640
|
+
account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
|
|
641
|
+
source, raw_json, updated_at
|
|
642
|
+
)
|
|
643
|
+
select
|
|
644
|
+
account_id,
|
|
645
|
+
id,
|
|
646
|
+
kind,
|
|
647
|
+
created_at,
|
|
648
|
+
created_at,
|
|
649
|
+
1,
|
|
650
|
+
'legacy',
|
|
651
|
+
'{}',
|
|
652
|
+
?
|
|
653
|
+
from tweets
|
|
654
|
+
where kind in ('home', 'mention')
|
|
655
|
+
`).run(now);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function ensureSchemaIndexes(db: Database) {
|
|
366
659
|
db.exec(INDEX_SQL);
|
|
367
660
|
}
|
|
368
661
|
|
|
@@ -371,17 +664,23 @@ function initDatabase(options: InitDatabaseOptions = {}) {
|
|
|
371
664
|
|
|
372
665
|
if (!nativeDb) {
|
|
373
666
|
const { dbPath } = getBirdclawPaths();
|
|
374
|
-
nativeDb = new
|
|
667
|
+
nativeDb = new NativeSqliteDatabase(dbPath);
|
|
375
668
|
nativeDb.exec(BASE_SCHEMA_SQL);
|
|
376
669
|
ensureAccountExternalUserIdColumn(nativeDb);
|
|
377
670
|
ensureTweetMetadataColumns(nativeDb);
|
|
378
671
|
ensureProfileAvatarColumns(nativeDb);
|
|
379
672
|
ensureTweetCollectionsTable(nativeDb);
|
|
673
|
+
ensureTweetAccountEdgesTable(nativeDb);
|
|
674
|
+
ensureProfileAffiliationsTable(nativeDb);
|
|
675
|
+
ensureProfileSnapshotsTable(nativeDb);
|
|
676
|
+
ensureProfileBioEntitiesTable(nativeDb);
|
|
677
|
+
ensureIdentitySearchIndexTable(nativeDb);
|
|
380
678
|
ensureSchemaIndexes(nativeDb);
|
|
381
679
|
if (options.seedDemoData !== false) {
|
|
382
680
|
seedDemoData(nativeDb);
|
|
383
681
|
}
|
|
384
682
|
backfillTweetCollections(nativeDb);
|
|
683
|
+
backfillTweetAccountEdges(nativeDb);
|
|
385
684
|
}
|
|
386
685
|
|
|
387
686
|
if (!kyselyDb) {
|
|
@@ -395,7 +694,7 @@ function initDatabase(options: InitDatabaseOptions = {}) {
|
|
|
395
694
|
|
|
396
695
|
export function getNativeDb(options: InitDatabaseOptions = {}) {
|
|
397
696
|
initDatabase(options);
|
|
398
|
-
return nativeDb as
|
|
697
|
+
return nativeDb as Database;
|
|
399
698
|
}
|
|
400
699
|
|
|
401
700
|
export function getDb() {
|