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
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import BetterSqlite3 from "better-sqlite3";
|
|
2
|
+
import { Kysely, SqliteDialect } from "kysely";
|
|
3
|
+
import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
|
|
4
|
+
import { seedDemoData } from "./seed";
|
|
5
|
+
|
|
6
|
+
export interface AccountsTable {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
handle: string;
|
|
10
|
+
transport: string;
|
|
11
|
+
is_default: number;
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProfilesTable {
|
|
16
|
+
id: string;
|
|
17
|
+
handle: string;
|
|
18
|
+
display_name: string;
|
|
19
|
+
bio: string;
|
|
20
|
+
followers_count: number;
|
|
21
|
+
avatar_hue: number;
|
|
22
|
+
avatar_url: string | null;
|
|
23
|
+
created_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TweetsTable {
|
|
27
|
+
id: string;
|
|
28
|
+
account_id: string;
|
|
29
|
+
author_profile_id: string;
|
|
30
|
+
kind: string;
|
|
31
|
+
text: string;
|
|
32
|
+
created_at: string;
|
|
33
|
+
is_replied: number;
|
|
34
|
+
reply_to_id: string | null;
|
|
35
|
+
like_count: number;
|
|
36
|
+
media_count: number;
|
|
37
|
+
bookmarked: number;
|
|
38
|
+
liked: number;
|
|
39
|
+
entities_json: string;
|
|
40
|
+
media_json: string;
|
|
41
|
+
quoted_tweet_id: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DmConversationsTable {
|
|
45
|
+
id: string;
|
|
46
|
+
account_id: string;
|
|
47
|
+
participant_profile_id: string;
|
|
48
|
+
title: string;
|
|
49
|
+
last_message_at: string;
|
|
50
|
+
unread_count: number;
|
|
51
|
+
needs_reply: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DmMessagesTable {
|
|
55
|
+
id: string;
|
|
56
|
+
conversation_id: string;
|
|
57
|
+
sender_profile_id: string;
|
|
58
|
+
text: string;
|
|
59
|
+
created_at: string;
|
|
60
|
+
direction: "inbound" | "outbound";
|
|
61
|
+
is_replied: number;
|
|
62
|
+
media_count: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TweetActionsTable {
|
|
66
|
+
id: string;
|
|
67
|
+
account_id: string;
|
|
68
|
+
tweet_id: string | null;
|
|
69
|
+
kind: string;
|
|
70
|
+
body: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface BlocksTable {
|
|
75
|
+
account_id: string;
|
|
76
|
+
profile_id: string;
|
|
77
|
+
source: string;
|
|
78
|
+
created_at: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MutesTable {
|
|
82
|
+
account_id: string;
|
|
83
|
+
profile_id: string;
|
|
84
|
+
source: string;
|
|
85
|
+
created_at: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface AiScoresTable {
|
|
89
|
+
entity_kind: string;
|
|
90
|
+
entity_id: string;
|
|
91
|
+
model: string;
|
|
92
|
+
score: number;
|
|
93
|
+
summary: string;
|
|
94
|
+
reasoning: string;
|
|
95
|
+
updated_at: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SyncCacheTable {
|
|
99
|
+
cache_key: string;
|
|
100
|
+
value_json: string;
|
|
101
|
+
updated_at: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BirdclawDatabase {
|
|
105
|
+
accounts: AccountsTable;
|
|
106
|
+
profiles: ProfilesTable;
|
|
107
|
+
tweets: TweetsTable;
|
|
108
|
+
dm_conversations: DmConversationsTable;
|
|
109
|
+
dm_messages: DmMessagesTable;
|
|
110
|
+
tweet_actions: TweetActionsTable;
|
|
111
|
+
blocks: BlocksTable;
|
|
112
|
+
mutes: MutesTable;
|
|
113
|
+
ai_scores: AiScoresTable;
|
|
114
|
+
sync_cache: SyncCacheTable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let nativeDb: BetterSqlite3.Database | undefined;
|
|
118
|
+
let kyselyDb: Kysely<BirdclawDatabase> | undefined;
|
|
119
|
+
|
|
120
|
+
const BASE_SCHEMA_SQL = `
|
|
121
|
+
pragma journal_mode = wal;
|
|
122
|
+
pragma busy_timeout = 5000;
|
|
123
|
+
pragma foreign_keys = on;
|
|
124
|
+
|
|
125
|
+
create table if not exists accounts (
|
|
126
|
+
id text primary key,
|
|
127
|
+
name text not null,
|
|
128
|
+
handle text not null unique,
|
|
129
|
+
transport text not null,
|
|
130
|
+
is_default integer not null default 0,
|
|
131
|
+
created_at text not null
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
create table if not exists profiles (
|
|
135
|
+
id text primary key,
|
|
136
|
+
handle text not null unique,
|
|
137
|
+
display_name text not null,
|
|
138
|
+
bio text not null,
|
|
139
|
+
followers_count integer not null default 0,
|
|
140
|
+
avatar_hue integer not null default 0,
|
|
141
|
+
avatar_url text,
|
|
142
|
+
created_at text not null
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
create table if not exists tweets (
|
|
146
|
+
id text primary key,
|
|
147
|
+
account_id text not null,
|
|
148
|
+
author_profile_id text not null,
|
|
149
|
+
kind text not null,
|
|
150
|
+
text text not null,
|
|
151
|
+
created_at text not null,
|
|
152
|
+
is_replied integer not null default 0,
|
|
153
|
+
reply_to_id text,
|
|
154
|
+
like_count integer not null default 0,
|
|
155
|
+
media_count integer not null default 0,
|
|
156
|
+
bookmarked integer not null default 0,
|
|
157
|
+
liked integer not null default 0,
|
|
158
|
+
entities_json text not null default '{}',
|
|
159
|
+
media_json text not null default '[]',
|
|
160
|
+
quoted_tweet_id text
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
create table if not exists dm_conversations (
|
|
164
|
+
id text primary key,
|
|
165
|
+
account_id text not null,
|
|
166
|
+
participant_profile_id text not null,
|
|
167
|
+
title text not null,
|
|
168
|
+
last_message_at text not null,
|
|
169
|
+
unread_count integer not null default 0,
|
|
170
|
+
needs_reply integer not null default 0
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
create table if not exists dm_messages (
|
|
174
|
+
id text primary key,
|
|
175
|
+
conversation_id text not null,
|
|
176
|
+
sender_profile_id text not null,
|
|
177
|
+
text text not null,
|
|
178
|
+
created_at text not null,
|
|
179
|
+
direction text not null,
|
|
180
|
+
is_replied integer not null default 0,
|
|
181
|
+
media_count integer not null default 0
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
create table if not exists tweet_actions (
|
|
185
|
+
id text primary key,
|
|
186
|
+
account_id text not null,
|
|
187
|
+
tweet_id text,
|
|
188
|
+
kind text not null,
|
|
189
|
+
body text not null,
|
|
190
|
+
created_at text not null
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
create table if not exists blocks (
|
|
194
|
+
account_id text not null,
|
|
195
|
+
profile_id text not null,
|
|
196
|
+
source text not null,
|
|
197
|
+
created_at text not null,
|
|
198
|
+
primary key (account_id, profile_id)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
create table if not exists mutes (
|
|
202
|
+
account_id text not null,
|
|
203
|
+
profile_id text not null,
|
|
204
|
+
source text not null,
|
|
205
|
+
created_at text not null,
|
|
206
|
+
primary key (account_id, profile_id)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
create table if not exists ai_scores (
|
|
210
|
+
entity_kind text not null,
|
|
211
|
+
entity_id text not null,
|
|
212
|
+
model text not null,
|
|
213
|
+
score integer not null,
|
|
214
|
+
summary text not null,
|
|
215
|
+
reasoning text not null,
|
|
216
|
+
updated_at text not null,
|
|
217
|
+
primary key (entity_kind, entity_id)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
create table if not exists sync_cache (
|
|
221
|
+
cache_key text primary key,
|
|
222
|
+
value_json text not null,
|
|
223
|
+
updated_at text not null
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
create virtual table if not exists tweets_fts using fts5(
|
|
227
|
+
tweet_id unindexed,
|
|
228
|
+
text
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
create virtual table if not exists dm_fts using fts5(
|
|
232
|
+
message_id unindexed,
|
|
233
|
+
text
|
|
234
|
+
);
|
|
235
|
+
`;
|
|
236
|
+
|
|
237
|
+
const INDEX_SQL = `
|
|
238
|
+
create index if not exists idx_tweets_kind_created on tweets(kind, created_at desc);
|
|
239
|
+
create index if not exists idx_tweets_account_created on tweets(account_id, created_at desc);
|
|
240
|
+
create index if not exists idx_tweets_quoted on tweets(quoted_tweet_id);
|
|
241
|
+
create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
|
|
242
|
+
create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
|
|
243
|
+
create index if not exists idx_profiles_followers on profiles(followers_count desc);
|
|
244
|
+
create index if not exists idx_blocks_account_created on blocks(account_id, created_at desc);
|
|
245
|
+
create index if not exists idx_mutes_account_created on mutes(account_id, created_at desc);
|
|
246
|
+
create index if not exists idx_ai_scores_updated on ai_scores(updated_at desc);
|
|
247
|
+
create index if not exists idx_sync_cache_updated on sync_cache(updated_at desc);
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
function getColumnNames(
|
|
251
|
+
db: BetterSqlite3.Database,
|
|
252
|
+
tableName: string,
|
|
253
|
+
): Set<string> {
|
|
254
|
+
const rows = db.prepare(`pragma table_info(${tableName})`).all() as Array<{
|
|
255
|
+
name: string;
|
|
256
|
+
}>;
|
|
257
|
+
return new Set(rows.map((row) => row.name));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function ensureTweetMetadataColumns(db: BetterSqlite3.Database) {
|
|
261
|
+
const columnNames = getColumnNames(db, "tweets");
|
|
262
|
+
if (!columnNames.has("entities_json")) {
|
|
263
|
+
db.exec(
|
|
264
|
+
"alter table tweets add column entities_json text not null default '{}'",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (!columnNames.has("media_json")) {
|
|
268
|
+
db.exec(
|
|
269
|
+
"alter table tweets add column media_json text not null default '[]'",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
if (!columnNames.has("quoted_tweet_id")) {
|
|
273
|
+
db.exec("alter table tweets add column quoted_tweet_id text");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function ensureProfileAvatarColumns(db: BetterSqlite3.Database) {
|
|
278
|
+
const columnNames = getColumnNames(db, "profiles");
|
|
279
|
+
if (!columnNames.has("avatar_url")) {
|
|
280
|
+
db.exec("alter table profiles add column avatar_url text");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function ensureSchemaIndexes(db: BetterSqlite3.Database) {
|
|
285
|
+
db.exec(INDEX_SQL);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function initDatabase() {
|
|
289
|
+
ensureBirdclawDirs();
|
|
290
|
+
|
|
291
|
+
if (!nativeDb) {
|
|
292
|
+
const { dbPath } = getBirdclawPaths();
|
|
293
|
+
nativeDb = new BetterSqlite3(dbPath);
|
|
294
|
+
nativeDb.exec(BASE_SCHEMA_SQL);
|
|
295
|
+
ensureTweetMetadataColumns(nativeDb);
|
|
296
|
+
ensureProfileAvatarColumns(nativeDb);
|
|
297
|
+
ensureSchemaIndexes(nativeDb);
|
|
298
|
+
seedDemoData(nativeDb);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!kyselyDb) {
|
|
302
|
+
kyselyDb = new Kysely<BirdclawDatabase>({
|
|
303
|
+
dialect: new SqliteDialect({
|
|
304
|
+
database: nativeDb,
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function getNativeDb() {
|
|
311
|
+
initDatabase();
|
|
312
|
+
return nativeDb as BetterSqlite3.Database;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function getDb() {
|
|
316
|
+
initDatabase();
|
|
317
|
+
return kyselyDb as Kysely<BirdclawDatabase>;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function resetDatabaseForTests() {
|
|
321
|
+
kyselyDb?.destroy();
|
|
322
|
+
kyselyDb = undefined;
|
|
323
|
+
|
|
324
|
+
nativeDb?.close();
|
|
325
|
+
nativeDb = undefined;
|
|
326
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import {
|
|
3
|
+
type BirdDmConversation,
|
|
4
|
+
type BirdDmEvent,
|
|
5
|
+
type BirdDmUser,
|
|
6
|
+
listDirectMessagesViaBird,
|
|
7
|
+
} from "./bird";
|
|
8
|
+
import { getNativeDb } from "./db";
|
|
9
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
10
|
+
import type { XurlMentionUser } from "./types";
|
|
11
|
+
import { upsertProfileFromXUser } from "./x-profile";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_DMS_CACHE_TTL_MS = 2 * 60_000;
|
|
14
|
+
|
|
15
|
+
function parseCacheTtlMs(value?: number) {
|
|
16
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
17
|
+
return DEFAULT_DMS_CACHE_TTL_MS;
|
|
18
|
+
}
|
|
19
|
+
return Math.floor(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertBirdLimit(limit: number) {
|
|
23
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
24
|
+
throw new Error("bird DM mode requires --limit of at least 1");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveAccount(db: Database.Database, accountId?: string) {
|
|
29
|
+
const row = accountId
|
|
30
|
+
? (db
|
|
31
|
+
.prepare("select id, handle from accounts where id = ?")
|
|
32
|
+
.get(accountId) as { id: string; handle: string } | undefined)
|
|
33
|
+
: (db
|
|
34
|
+
.prepare(
|
|
35
|
+
`
|
|
36
|
+
select id, handle
|
|
37
|
+
from accounts
|
|
38
|
+
order by is_default desc, created_at asc
|
|
39
|
+
limit 1
|
|
40
|
+
`,
|
|
41
|
+
)
|
|
42
|
+
.get() as { id: string; handle: string } | undefined);
|
|
43
|
+
|
|
44
|
+
if (!row) {
|
|
45
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
accountId: row.id,
|
|
50
|
+
username: row.handle.replace(/^@/, ""),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toIsoTimestamp(value?: string) {
|
|
55
|
+
if (!value) {
|
|
56
|
+
return new Date().toISOString();
|
|
57
|
+
}
|
|
58
|
+
const parsed = new Date(value);
|
|
59
|
+
return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toXUser(user: BirdDmUser): XurlMentionUser {
|
|
63
|
+
return {
|
|
64
|
+
id: user.id,
|
|
65
|
+
username: user.username ?? `user_${user.id}`,
|
|
66
|
+
name: user.name ?? user.username ?? `user_${user.id}`,
|
|
67
|
+
profile_image_url: user.profileImageUrl,
|
|
68
|
+
public_metrics: { followers_count: 0 },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectUsers(payload: {
|
|
73
|
+
conversations: BirdDmConversation[];
|
|
74
|
+
events: BirdDmEvent[];
|
|
75
|
+
}) {
|
|
76
|
+
const users = new Map<string, BirdDmUser>();
|
|
77
|
+
const add = (user?: BirdDmUser) => {
|
|
78
|
+
if (!user?.id) return;
|
|
79
|
+
users.set(user.id, { ...users.get(user.id), ...user });
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const conversation of payload.conversations) {
|
|
83
|
+
for (const participant of conversation.participants) {
|
|
84
|
+
add(participant);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const event of payload.events) {
|
|
88
|
+
add(event.sender);
|
|
89
|
+
add(event.recipient);
|
|
90
|
+
}
|
|
91
|
+
return users;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getLocalExternalUserId(
|
|
95
|
+
users: Map<string, BirdDmUser>,
|
|
96
|
+
accountUsername: string,
|
|
97
|
+
) {
|
|
98
|
+
for (const user of users.values()) {
|
|
99
|
+
if (user.username === accountUsername) {
|
|
100
|
+
return user.id;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getLatestEvent(events: BirdDmEvent[]) {
|
|
107
|
+
return [...events].sort(
|
|
108
|
+
(left, right) =>
|
|
109
|
+
new Date(right.createdAt ?? 0).getTime() -
|
|
110
|
+
new Date(left.createdAt ?? 0).getTime(),
|
|
111
|
+
)[0];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mergeDirectMessagesIntoLocalStore(
|
|
115
|
+
db: Database.Database,
|
|
116
|
+
accountId: string,
|
|
117
|
+
accountUsername: string,
|
|
118
|
+
payload: {
|
|
119
|
+
conversations: BirdDmConversation[];
|
|
120
|
+
events: BirdDmEvent[];
|
|
121
|
+
},
|
|
122
|
+
) {
|
|
123
|
+
const users = collectUsers(payload);
|
|
124
|
+
const localExternalUserId = getLocalExternalUserId(users, accountUsername);
|
|
125
|
+
const profilesByExternalId = new Map<string, string>();
|
|
126
|
+
for (const user of users.values()) {
|
|
127
|
+
const resolved = upsertProfileFromXUser(db, toXUser(user));
|
|
128
|
+
profilesByExternalId.set(user.id, resolved.profile.id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const eventsByConversation = new Map<string, BirdDmEvent[]>();
|
|
132
|
+
for (const event of payload.events) {
|
|
133
|
+
if (!event.conversationId) continue;
|
|
134
|
+
const events = eventsByConversation.get(event.conversationId) ?? [];
|
|
135
|
+
events.push(event);
|
|
136
|
+
eventsByConversation.set(event.conversationId, events);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const upsertConversation = db.prepare(`
|
|
140
|
+
insert into dm_conversations (
|
|
141
|
+
id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
|
|
142
|
+
) values (?, ?, ?, ?, ?, 0, ?)
|
|
143
|
+
on conflict(id) do update set
|
|
144
|
+
account_id = excluded.account_id,
|
|
145
|
+
participant_profile_id = excluded.participant_profile_id,
|
|
146
|
+
title = excluded.title,
|
|
147
|
+
last_message_at = excluded.last_message_at,
|
|
148
|
+
needs_reply = excluded.needs_reply
|
|
149
|
+
`);
|
|
150
|
+
const upsertMessage = db.prepare(`
|
|
151
|
+
insert into dm_messages (
|
|
152
|
+
id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
|
|
153
|
+
) values (?, ?, ?, ?, ?, ?, 0, 0)
|
|
154
|
+
on conflict(id) do update set
|
|
155
|
+
conversation_id = excluded.conversation_id,
|
|
156
|
+
sender_profile_id = excluded.sender_profile_id,
|
|
157
|
+
text = excluded.text,
|
|
158
|
+
created_at = excluded.created_at,
|
|
159
|
+
direction = excluded.direction,
|
|
160
|
+
media_count = excluded.media_count
|
|
161
|
+
`);
|
|
162
|
+
const replaceFts = db.prepare("delete from dm_fts where message_id = ?");
|
|
163
|
+
const insertFts = db.prepare(
|
|
164
|
+
"insert into dm_fts (message_id, text) values (?, ?)",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
db.transaction(() => {
|
|
168
|
+
for (const conversation of payload.conversations) {
|
|
169
|
+
const events = eventsByConversation.get(conversation.id) ?? [];
|
|
170
|
+
if (events.length === 0) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const participant =
|
|
175
|
+
conversation.participants.find(
|
|
176
|
+
(user) =>
|
|
177
|
+
user.id !== localExternalUserId &&
|
|
178
|
+
user.username !== accountUsername,
|
|
179
|
+
) ?? conversation.participants[0];
|
|
180
|
+
if (!participant) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const participantProfileId = profilesByExternalId.get(participant.id);
|
|
184
|
+
if (!participantProfileId) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const latest = getLatestEvent(events);
|
|
189
|
+
const lastMessageAt = toIsoTimestamp(
|
|
190
|
+
latest?.createdAt ?? conversation.lastMessageAt,
|
|
191
|
+
);
|
|
192
|
+
const latestInbound =
|
|
193
|
+
latest?.senderId !== localExternalUserId &&
|
|
194
|
+
latest?.sender?.username !== accountUsername;
|
|
195
|
+
upsertConversation.run(
|
|
196
|
+
conversation.id,
|
|
197
|
+
accountId,
|
|
198
|
+
participantProfileId,
|
|
199
|
+
participant.username ?? participant.name ?? participant.id,
|
|
200
|
+
lastMessageAt,
|
|
201
|
+
latestInbound ? 1 : 0,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
for (const event of events) {
|
|
205
|
+
const senderId = event.senderId ?? event.sender?.id;
|
|
206
|
+
if (!senderId) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const senderProfileId = profilesByExternalId.get(senderId);
|
|
210
|
+
if (!senderProfileId) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const direction =
|
|
214
|
+
senderId === localExternalUserId ||
|
|
215
|
+
event.sender?.username === accountUsername
|
|
216
|
+
? "outbound"
|
|
217
|
+
: "inbound";
|
|
218
|
+
upsertMessage.run(
|
|
219
|
+
event.id,
|
|
220
|
+
conversation.id,
|
|
221
|
+
senderProfileId,
|
|
222
|
+
event.text,
|
|
223
|
+
toIsoTimestamp(event.createdAt),
|
|
224
|
+
direction,
|
|
225
|
+
);
|
|
226
|
+
replaceFts.run(event.id);
|
|
227
|
+
insertFts.run(event.id, event.text);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
})();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function syncDirectMessagesViaCachedBird({
|
|
234
|
+
account,
|
|
235
|
+
limit = 20,
|
|
236
|
+
refresh = false,
|
|
237
|
+
cacheTtlMs,
|
|
238
|
+
}: {
|
|
239
|
+
account?: string;
|
|
240
|
+
limit?: number;
|
|
241
|
+
refresh?: boolean;
|
|
242
|
+
cacheTtlMs?: number;
|
|
243
|
+
}) {
|
|
244
|
+
assertBirdLimit(limit);
|
|
245
|
+
const db = getNativeDb();
|
|
246
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
247
|
+
const cacheKey = `dms:bird:${resolvedAccount.accountId}:${String(limit)}`;
|
|
248
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
249
|
+
const cached = readSyncCache<{
|
|
250
|
+
conversations: BirdDmConversation[];
|
|
251
|
+
events: BirdDmEvent[];
|
|
252
|
+
}>(cacheKey, db);
|
|
253
|
+
const cacheAgeMs = cached
|
|
254
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
255
|
+
: Number.POSITIVE_INFINITY;
|
|
256
|
+
|
|
257
|
+
const payload =
|
|
258
|
+
!refresh && cached && cacheAgeMs <= ttlMs
|
|
259
|
+
? cached.value
|
|
260
|
+
: await listDirectMessagesViaBird({ maxResults: limit });
|
|
261
|
+
|
|
262
|
+
mergeDirectMessagesIntoLocalStore(
|
|
263
|
+
db,
|
|
264
|
+
resolvedAccount.accountId,
|
|
265
|
+
resolvedAccount.username,
|
|
266
|
+
payload,
|
|
267
|
+
);
|
|
268
|
+
if (!cached || refresh || cacheAgeMs > ttlMs) {
|
|
269
|
+
writeSyncCache(cacheKey, payload, db);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
ok: true,
|
|
274
|
+
source: cached && !refresh && cacheAgeMs <= ttlMs ? "cache" : "bird",
|
|
275
|
+
accountId: resolvedAccount.accountId,
|
|
276
|
+
conversations: payload.conversations.length,
|
|
277
|
+
messages: payload.events.length,
|
|
278
|
+
};
|
|
279
|
+
}
|