birdclaw 0.1.0 → 0.2.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/README.md +117 -10
- package/bin/birdclaw.mjs +3 -1
- package/package.json +4 -3
- package/src/cli.ts +237 -4
- package/src/components/AppNav.tsx +2 -0
- package/src/components/SavedTimelineView.tsx +115 -0
- package/src/components/TimelineCard.tsx +26 -17
- package/src/lib/archive-import.ts +193 -16
- package/src/lib/backup.ts +1617 -0
- package/src/lib/bird.ts +163 -24
- package/src/lib/blocks-write.ts +2 -2
- package/src/lib/blocks.ts +2 -7
- package/src/lib/bookmark-sync-job.ts +446 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/db.ts +82 -4
- package/src/lib/mentions-live.ts +2 -5
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/mutes-write.ts +2 -2
- package/src/lib/queries.ts +70 -1
- package/src/lib/seed.ts +4 -2
- package/src/lib/theme-transition.ts +6 -10
- package/src/lib/theme.tsx +13 -5
- package/src/lib/timeline-collections-live.ts +406 -0
- package/src/lib/types.ts +9 -1
- package/src/lib/xurl.ts +90 -4
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/__root.tsx +6 -1
- package/src/routes/api/action.tsx +14 -4
- package/src/routes/api/inbox.tsx +3 -1
- package/src/routes/api/query.tsx +20 -2
- package/src/routes/api/status.tsx +6 -3
- package/src/routes/bookmarks.tsx +18 -0
- package/src/routes/likes.tsx +18 -0
- package/tsconfig.json +0 -1
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import type Database from "better-sqlite3";
|
|
7
|
+
import { maybeAutoSyncBackup, type BackupAutoUpdateResult } from "./backup";
|
|
8
|
+
import { ensureBirdclawDirs, getBirdclawPaths } from "./config";
|
|
9
|
+
import { getNativeDb } from "./db";
|
|
10
|
+
import {
|
|
11
|
+
syncTimelineCollection,
|
|
12
|
+
type TimelineCollectionMode,
|
|
13
|
+
} from "./timeline-collections-live";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const DEFAULT_BOOKMARK_SYNC_INTERVAL_SECONDS = 3 * 60 * 60;
|
|
17
|
+
const DEFAULT_BOOKMARK_SYNC_LIMIT = 100;
|
|
18
|
+
const DEFAULT_BOOKMARK_SYNC_MAX_PAGES = 5;
|
|
19
|
+
const DEFAULT_LAUNCHD_LABEL = "com.steipete.birdclaw.bookmarks-sync";
|
|
20
|
+
const DEFAULT_LAUNCHD_PATH =
|
|
21
|
+
"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
22
|
+
const DEFAULT_LOCK_STALE_MS = 6 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
export interface BookmarkSyncJobOptions {
|
|
25
|
+
account?: string;
|
|
26
|
+
mode?: TimelineCollectionMode;
|
|
27
|
+
limit?: number;
|
|
28
|
+
all?: boolean;
|
|
29
|
+
maxPages?: number;
|
|
30
|
+
refresh?: boolean;
|
|
31
|
+
cacheTtlMs?: number;
|
|
32
|
+
logPath?: string;
|
|
33
|
+
lockPath?: string;
|
|
34
|
+
db?: Database.Database;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BookmarkSyncAuditEntry {
|
|
38
|
+
job: "bookmarks-sync";
|
|
39
|
+
ok: boolean;
|
|
40
|
+
startedAt: string;
|
|
41
|
+
finishedAt: string;
|
|
42
|
+
durationMs: number;
|
|
43
|
+
host: string;
|
|
44
|
+
pid: number;
|
|
45
|
+
options: {
|
|
46
|
+
account?: string;
|
|
47
|
+
mode: TimelineCollectionMode;
|
|
48
|
+
limit: number;
|
|
49
|
+
all: boolean;
|
|
50
|
+
maxPages?: number;
|
|
51
|
+
refresh: boolean;
|
|
52
|
+
cacheTtlMs?: number;
|
|
53
|
+
};
|
|
54
|
+
before: {
|
|
55
|
+
bookmarks: number;
|
|
56
|
+
};
|
|
57
|
+
after: {
|
|
58
|
+
bookmarks: number;
|
|
59
|
+
};
|
|
60
|
+
added: number;
|
|
61
|
+
skipped?: "already-running";
|
|
62
|
+
sync?: {
|
|
63
|
+
source: string;
|
|
64
|
+
count: number;
|
|
65
|
+
accountId: string;
|
|
66
|
+
};
|
|
67
|
+
backup?: BackupAutoUpdateResult;
|
|
68
|
+
error?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BookmarkSyncLaunchAgentOptions {
|
|
72
|
+
label?: string;
|
|
73
|
+
intervalSeconds?: number;
|
|
74
|
+
program?: string;
|
|
75
|
+
mode?: TimelineCollectionMode;
|
|
76
|
+
limit?: number;
|
|
77
|
+
all?: boolean;
|
|
78
|
+
maxPages?: number;
|
|
79
|
+
refresh?: boolean;
|
|
80
|
+
cacheTtlSeconds?: number;
|
|
81
|
+
logPath?: string;
|
|
82
|
+
envFile?: string;
|
|
83
|
+
stdoutPath?: string;
|
|
84
|
+
stderrPath?: string;
|
|
85
|
+
launchAgentsDir?: string;
|
|
86
|
+
load?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BookmarkSyncLaunchAgentInstallResult {
|
|
90
|
+
ok: true;
|
|
91
|
+
label: string;
|
|
92
|
+
plistPath: string;
|
|
93
|
+
loaded: boolean;
|
|
94
|
+
programArguments: string[];
|
|
95
|
+
logPath: string;
|
|
96
|
+
stdoutPath: string;
|
|
97
|
+
stderrPath: string;
|
|
98
|
+
intervalSeconds: number;
|
|
99
|
+
envFile?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expandHome(input: string) {
|
|
103
|
+
return input === "~" || input.startsWith("~/")
|
|
104
|
+
? path.join(os.homedir(), input.slice(2))
|
|
105
|
+
: input;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolvePath(input: string) {
|
|
109
|
+
return path.resolve(expandHome(input));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getDefaultBookmarkSyncAuditLogPath() {
|
|
113
|
+
return path.join(getBirdclawPaths().rootDir, "audit", "bookmarks-sync.jsonl");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getDefaultBookmarkSyncLockPath() {
|
|
117
|
+
return path.join(getBirdclawPaths().rootDir, "locks", "bookmarks-sync.lock");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function countBookmarks(db: Database.Database) {
|
|
121
|
+
const row = db
|
|
122
|
+
.prepare("select count(*) as count from tweet_collections where kind = ?")
|
|
123
|
+
.get("bookmarks") as { count: number };
|
|
124
|
+
return row.count;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function appendAuditEntry(
|
|
128
|
+
logPath: string,
|
|
129
|
+
entry: BookmarkSyncAuditEntry,
|
|
130
|
+
) {
|
|
131
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
132
|
+
await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function messageFromError(error: unknown) {
|
|
136
|
+
return error instanceof Error ? error.message : String(error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function acquireLock(lockPath: string) {
|
|
140
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
141
|
+
try {
|
|
142
|
+
const handle = await fs.open(lockPath, "wx");
|
|
143
|
+
await handle.writeFile(
|
|
144
|
+
`${JSON.stringify({
|
|
145
|
+
pid: process.pid,
|
|
146
|
+
host: os.hostname(),
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
})}\n`,
|
|
149
|
+
"utf8",
|
|
150
|
+
);
|
|
151
|
+
await handle.close();
|
|
152
|
+
return async () => {
|
|
153
|
+
await fs.rm(lockPath, { force: true });
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (
|
|
157
|
+
typeof error === "object" &&
|
|
158
|
+
error !== null &&
|
|
159
|
+
"code" in error &&
|
|
160
|
+
error.code === "EEXIST"
|
|
161
|
+
) {
|
|
162
|
+
const stats = await fs.stat(lockPath).catch(() => undefined);
|
|
163
|
+
if (stats && Date.now() - stats.mtimeMs > DEFAULT_LOCK_STALE_MS) {
|
|
164
|
+
await fs.rm(lockPath, { force: true });
|
|
165
|
+
return acquireLock(lockPath);
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function runBookmarkSyncJob({
|
|
174
|
+
account,
|
|
175
|
+
mode = "auto",
|
|
176
|
+
limit = DEFAULT_BOOKMARK_SYNC_LIMIT,
|
|
177
|
+
all,
|
|
178
|
+
maxPages = DEFAULT_BOOKMARK_SYNC_MAX_PAGES,
|
|
179
|
+
refresh = true,
|
|
180
|
+
cacheTtlMs,
|
|
181
|
+
logPath,
|
|
182
|
+
lockPath,
|
|
183
|
+
db,
|
|
184
|
+
}: BookmarkSyncJobOptions = {}): Promise<BookmarkSyncAuditEntry> {
|
|
185
|
+
ensureBirdclawDirs();
|
|
186
|
+
const database = db ?? getNativeDb({ seedDemoData: false });
|
|
187
|
+
const resolvedLogPath = resolvePath(
|
|
188
|
+
logPath ?? getDefaultBookmarkSyncAuditLogPath(),
|
|
189
|
+
);
|
|
190
|
+
const resolvedLockPath = resolvePath(
|
|
191
|
+
lockPath ?? getDefaultBookmarkSyncLockPath(),
|
|
192
|
+
);
|
|
193
|
+
const effectiveAll = all ?? maxPages !== undefined;
|
|
194
|
+
const started = Date.now();
|
|
195
|
+
const startedAt = new Date(started).toISOString();
|
|
196
|
+
const before = { bookmarks: countBookmarks(database) };
|
|
197
|
+
const options = {
|
|
198
|
+
...(account ? { account } : {}),
|
|
199
|
+
mode,
|
|
200
|
+
limit,
|
|
201
|
+
all: effectiveAll,
|
|
202
|
+
...(maxPages === undefined ? {} : { maxPages }),
|
|
203
|
+
refresh,
|
|
204
|
+
...(cacheTtlMs === undefined ? {} : { cacheTtlMs }),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const releaseLock = await acquireLock(resolvedLockPath);
|
|
208
|
+
if (!releaseLock) {
|
|
209
|
+
const finished = Date.now();
|
|
210
|
+
const entry: BookmarkSyncAuditEntry = {
|
|
211
|
+
job: "bookmarks-sync",
|
|
212
|
+
ok: true,
|
|
213
|
+
startedAt,
|
|
214
|
+
finishedAt: new Date(finished).toISOString(),
|
|
215
|
+
durationMs: finished - started,
|
|
216
|
+
host: os.hostname(),
|
|
217
|
+
pid: process.pid,
|
|
218
|
+
options,
|
|
219
|
+
before,
|
|
220
|
+
after: before,
|
|
221
|
+
added: 0,
|
|
222
|
+
skipped: "already-running",
|
|
223
|
+
};
|
|
224
|
+
await appendAuditEntry(resolvedLogPath, entry);
|
|
225
|
+
return entry;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const sync = await syncTimelineCollection({
|
|
230
|
+
kind: "bookmarks",
|
|
231
|
+
account,
|
|
232
|
+
mode,
|
|
233
|
+
limit,
|
|
234
|
+
all: effectiveAll,
|
|
235
|
+
maxPages,
|
|
236
|
+
refresh,
|
|
237
|
+
cacheTtlMs,
|
|
238
|
+
});
|
|
239
|
+
const backup = await maybeAutoSyncBackup(database);
|
|
240
|
+
const finished = Date.now();
|
|
241
|
+
const after = { bookmarks: countBookmarks(database) };
|
|
242
|
+
const entry: BookmarkSyncAuditEntry = {
|
|
243
|
+
job: "bookmarks-sync",
|
|
244
|
+
ok: true,
|
|
245
|
+
startedAt,
|
|
246
|
+
finishedAt: new Date(finished).toISOString(),
|
|
247
|
+
durationMs: finished - started,
|
|
248
|
+
host: os.hostname(),
|
|
249
|
+
pid: process.pid,
|
|
250
|
+
options,
|
|
251
|
+
before,
|
|
252
|
+
after,
|
|
253
|
+
added: Math.max(0, after.bookmarks - before.bookmarks),
|
|
254
|
+
sync: {
|
|
255
|
+
source: sync.source,
|
|
256
|
+
count: sync.count,
|
|
257
|
+
accountId: sync.accountId,
|
|
258
|
+
},
|
|
259
|
+
backup,
|
|
260
|
+
};
|
|
261
|
+
await appendAuditEntry(resolvedLogPath, entry);
|
|
262
|
+
return entry;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const finished = Date.now();
|
|
265
|
+
const after = { bookmarks: countBookmarks(database) };
|
|
266
|
+
const entry: BookmarkSyncAuditEntry = {
|
|
267
|
+
job: "bookmarks-sync",
|
|
268
|
+
ok: false,
|
|
269
|
+
startedAt,
|
|
270
|
+
finishedAt: new Date(finished).toISOString(),
|
|
271
|
+
durationMs: finished - started,
|
|
272
|
+
host: os.hostname(),
|
|
273
|
+
pid: process.pid,
|
|
274
|
+
options,
|
|
275
|
+
before,
|
|
276
|
+
after,
|
|
277
|
+
added: Math.max(0, after.bookmarks - before.bookmarks),
|
|
278
|
+
error: messageFromError(error),
|
|
279
|
+
};
|
|
280
|
+
await appendAuditEntry(resolvedLogPath, entry);
|
|
281
|
+
return entry;
|
|
282
|
+
} finally {
|
|
283
|
+
await releaseLock();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function xmlEscape(value: string) {
|
|
288
|
+
return value
|
|
289
|
+
.replaceAll("&", "&")
|
|
290
|
+
.replaceAll("<", "<")
|
|
291
|
+
.replaceAll(">", ">");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function stringEntry(value: string) {
|
|
295
|
+
return `<string>${xmlEscape(value)}</string>`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function shellQuote(value: string) {
|
|
299
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildProgramArguments({
|
|
303
|
+
program = "birdclaw",
|
|
304
|
+
mode = "auto",
|
|
305
|
+
limit = DEFAULT_BOOKMARK_SYNC_LIMIT,
|
|
306
|
+
all = false,
|
|
307
|
+
maxPages = DEFAULT_BOOKMARK_SYNC_MAX_PAGES,
|
|
308
|
+
refresh = true,
|
|
309
|
+
cacheTtlSeconds,
|
|
310
|
+
logPath,
|
|
311
|
+
envFile,
|
|
312
|
+
}: BookmarkSyncLaunchAgentOptions) {
|
|
313
|
+
const args =
|
|
314
|
+
path.isAbsolute(program) || program.includes("/")
|
|
315
|
+
? [program]
|
|
316
|
+
: ["/usr/bin/env", program];
|
|
317
|
+
args.push(
|
|
318
|
+
"--json",
|
|
319
|
+
"jobs",
|
|
320
|
+
"sync-bookmarks",
|
|
321
|
+
"--mode",
|
|
322
|
+
mode,
|
|
323
|
+
"--limit",
|
|
324
|
+
String(limit),
|
|
325
|
+
"--log",
|
|
326
|
+
resolvePath(logPath ?? getDefaultBookmarkSyncAuditLogPath()),
|
|
327
|
+
);
|
|
328
|
+
if (all || maxPages !== undefined) {
|
|
329
|
+
args.push("--all");
|
|
330
|
+
}
|
|
331
|
+
if (maxPages !== undefined) {
|
|
332
|
+
args.push("--max-pages", String(maxPages));
|
|
333
|
+
}
|
|
334
|
+
if (refresh) {
|
|
335
|
+
args.push("--refresh");
|
|
336
|
+
}
|
|
337
|
+
if (cacheTtlSeconds !== undefined) {
|
|
338
|
+
args.push("--cache-ttl", String(cacheTtlSeconds));
|
|
339
|
+
}
|
|
340
|
+
if (envFile) {
|
|
341
|
+
const resolvedEnvFile = resolvePath(envFile);
|
|
342
|
+
return [
|
|
343
|
+
"/bin/bash",
|
|
344
|
+
"-lc",
|
|
345
|
+
[
|
|
346
|
+
"set -a",
|
|
347
|
+
`[ ! -f ${shellQuote(resolvedEnvFile)} ] || . ${shellQuote(resolvedEnvFile)}`,
|
|
348
|
+
"set +a",
|
|
349
|
+
`exec ${args.map(shellQuote).join(" ")}`,
|
|
350
|
+
].join("; "),
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
return args;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function buildBookmarkSyncLaunchAgentPlist(
|
|
357
|
+
options: BookmarkSyncLaunchAgentOptions = {},
|
|
358
|
+
) {
|
|
359
|
+
const label = options.label ?? DEFAULT_LAUNCHD_LABEL;
|
|
360
|
+
const intervalSeconds =
|
|
361
|
+
options.intervalSeconds ?? DEFAULT_BOOKMARK_SYNC_INTERVAL_SECONDS;
|
|
362
|
+
const logPath = resolvePath(
|
|
363
|
+
options.logPath ?? getDefaultBookmarkSyncAuditLogPath(),
|
|
364
|
+
);
|
|
365
|
+
const stdoutPath = resolvePath(
|
|
366
|
+
options.stdoutPath ??
|
|
367
|
+
path.join(getBirdclawPaths().rootDir, "logs", "bookmarks-sync.out.log"),
|
|
368
|
+
);
|
|
369
|
+
const stderrPath = resolvePath(
|
|
370
|
+
options.stderrPath ??
|
|
371
|
+
path.join(getBirdclawPaths().rootDir, "logs", "bookmarks-sync.err.log"),
|
|
372
|
+
);
|
|
373
|
+
const programArguments = buildProgramArguments({ ...options, logPath });
|
|
374
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
375
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
376
|
+
<plist version="1.0">
|
|
377
|
+
<dict>
|
|
378
|
+
<key>Label</key>
|
|
379
|
+
${stringEntry(label)}
|
|
380
|
+
<key>ProgramArguments</key>
|
|
381
|
+
<array>
|
|
382
|
+
${programArguments.map(stringEntry).join("\n ")}
|
|
383
|
+
</array>
|
|
384
|
+
<key>StartInterval</key>
|
|
385
|
+
<integer>${String(intervalSeconds)}</integer>
|
|
386
|
+
<key>RunAtLoad</key>
|
|
387
|
+
<true/>
|
|
388
|
+
<key>StandardOutPath</key>
|
|
389
|
+
${stringEntry(stdoutPath)}
|
|
390
|
+
<key>StandardErrorPath</key>
|
|
391
|
+
${stringEntry(stderrPath)}
|
|
392
|
+
<key>EnvironmentVariables</key>
|
|
393
|
+
<dict>
|
|
394
|
+
<key>PATH</key>
|
|
395
|
+
${stringEntry(DEFAULT_LAUNCHD_PATH)}
|
|
396
|
+
</dict>
|
|
397
|
+
</dict>
|
|
398
|
+
</plist>
|
|
399
|
+
`;
|
|
400
|
+
return {
|
|
401
|
+
label,
|
|
402
|
+
intervalSeconds,
|
|
403
|
+
logPath,
|
|
404
|
+
...(options.envFile ? { envFile: resolvePath(options.envFile) } : {}),
|
|
405
|
+
stdoutPath,
|
|
406
|
+
stderrPath,
|
|
407
|
+
programArguments,
|
|
408
|
+
plist,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function installBookmarkSyncLaunchAgent(
|
|
413
|
+
options: BookmarkSyncLaunchAgentOptions = {},
|
|
414
|
+
): Promise<BookmarkSyncLaunchAgentInstallResult> {
|
|
415
|
+
ensureBirdclawDirs();
|
|
416
|
+
const agent = buildBookmarkSyncLaunchAgentPlist(options);
|
|
417
|
+
const launchAgentsDir = resolvePath(
|
|
418
|
+
options.launchAgentsDir ?? "~/Library/LaunchAgents",
|
|
419
|
+
);
|
|
420
|
+
const plistPath = path.join(launchAgentsDir, `${agent.label}.plist`);
|
|
421
|
+
await fs.mkdir(launchAgentsDir, { recursive: true });
|
|
422
|
+
await fs.mkdir(path.dirname(agent.logPath), { recursive: true });
|
|
423
|
+
await fs.mkdir(path.dirname(agent.stdoutPath), { recursive: true });
|
|
424
|
+
await fs.mkdir(path.dirname(agent.stderrPath), { recursive: true });
|
|
425
|
+
await fs.writeFile(plistPath, agent.plist, "utf8");
|
|
426
|
+
|
|
427
|
+
let loaded = false;
|
|
428
|
+
if (options.load !== false) {
|
|
429
|
+
await execFileAsync("launchctl", ["unload", plistPath]).catch(() => {});
|
|
430
|
+
await execFileAsync("launchctl", ["load", "-w", plistPath]);
|
|
431
|
+
loaded = true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
label: agent.label,
|
|
437
|
+
plistPath,
|
|
438
|
+
loaded,
|
|
439
|
+
programArguments: agent.programArguments,
|
|
440
|
+
logPath: agent.logPath,
|
|
441
|
+
stdoutPath: agent.stdoutPath,
|
|
442
|
+
stderrPath: agent.stderrPath,
|
|
443
|
+
intervalSeconds: agent.intervalSeconds,
|
|
444
|
+
...(agent.envFile ? { envFile: agent.envFile } : {}),
|
|
445
|
+
};
|
|
446
|
+
}
|
package/src/lib/config.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface BirdclawConfig {
|
|
|
21
21
|
actions?: {
|
|
22
22
|
transport?: ActionsTransport;
|
|
23
23
|
};
|
|
24
|
+
backup?: {
|
|
25
|
+
repoPath?: string;
|
|
26
|
+
remote?: string;
|
|
27
|
+
autoSync?: boolean;
|
|
28
|
+
staleAfterSeconds?: number;
|
|
29
|
+
};
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
let cachedPaths: BirdclawPaths | undefined;
|
package/src/lib/db.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface AccountsTable {
|
|
|
7
7
|
id: string;
|
|
8
8
|
name: string;
|
|
9
9
|
handle: string;
|
|
10
|
+
external_user_id: string | null;
|
|
10
11
|
transport: string;
|
|
11
12
|
is_default: number;
|
|
12
13
|
created_at: string;
|
|
@@ -41,6 +42,16 @@ export interface TweetsTable {
|
|
|
41
42
|
quoted_tweet_id: string | null;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
export interface TweetCollectionsTable {
|
|
46
|
+
account_id: string;
|
|
47
|
+
tweet_id: string;
|
|
48
|
+
kind: "likes" | "bookmarks";
|
|
49
|
+
collected_at: string | null;
|
|
50
|
+
source: string;
|
|
51
|
+
raw_json: string;
|
|
52
|
+
updated_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
export interface DmConversationsTable {
|
|
45
56
|
id: string;
|
|
46
57
|
account_id: string;
|
|
@@ -105,6 +116,7 @@ export interface BirdclawDatabase {
|
|
|
105
116
|
accounts: AccountsTable;
|
|
106
117
|
profiles: ProfilesTable;
|
|
107
118
|
tweets: TweetsTable;
|
|
119
|
+
tweet_collections: TweetCollectionsTable;
|
|
108
120
|
dm_conversations: DmConversationsTable;
|
|
109
121
|
dm_messages: DmMessagesTable;
|
|
110
122
|
tweet_actions: TweetActionsTable;
|
|
@@ -117,6 +129,10 @@ export interface BirdclawDatabase {
|
|
|
117
129
|
let nativeDb: BetterSqlite3.Database | undefined;
|
|
118
130
|
let kyselyDb: Kysely<BirdclawDatabase> | undefined;
|
|
119
131
|
|
|
132
|
+
export interface InitDatabaseOptions {
|
|
133
|
+
seedDemoData?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
const BASE_SCHEMA_SQL = `
|
|
121
137
|
pragma journal_mode = wal;
|
|
122
138
|
pragma busy_timeout = 5000;
|
|
@@ -126,6 +142,7 @@ const BASE_SCHEMA_SQL = `
|
|
|
126
142
|
id text primary key,
|
|
127
143
|
name text not null,
|
|
128
144
|
handle text not null unique,
|
|
145
|
+
external_user_id text,
|
|
129
146
|
transport text not null,
|
|
130
147
|
is_default integer not null default 0,
|
|
131
148
|
created_at text not null
|
|
@@ -160,6 +177,17 @@ const BASE_SCHEMA_SQL = `
|
|
|
160
177
|
quoted_tweet_id text
|
|
161
178
|
);
|
|
162
179
|
|
|
180
|
+
create table if not exists tweet_collections (
|
|
181
|
+
account_id text not null,
|
|
182
|
+
tweet_id text not null,
|
|
183
|
+
kind text not null,
|
|
184
|
+
collected_at text,
|
|
185
|
+
source text not null,
|
|
186
|
+
raw_json text not null default '{}',
|
|
187
|
+
updated_at text not null,
|
|
188
|
+
primary key (account_id, tweet_id, kind)
|
|
189
|
+
);
|
|
190
|
+
|
|
163
191
|
create table if not exists dm_conversations (
|
|
164
192
|
id text primary key,
|
|
165
193
|
account_id text not null,
|
|
@@ -238,6 +266,8 @@ const INDEX_SQL = `
|
|
|
238
266
|
create index if not exists idx_tweets_kind_created on tweets(kind, created_at desc);
|
|
239
267
|
create index if not exists idx_tweets_account_created on tweets(account_id, created_at desc);
|
|
240
268
|
create index if not exists idx_tweets_quoted on tweets(quoted_tweet_id);
|
|
269
|
+
create index if not exists idx_tweet_collections_kind_account on tweet_collections(kind, account_id, collected_at desc, tweet_id);
|
|
270
|
+
create index if not exists idx_tweet_collections_tweet on tweet_collections(tweet_id);
|
|
241
271
|
create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
|
|
242
272
|
create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
|
|
243
273
|
create index if not exists idx_profiles_followers on profiles(followers_count desc);
|
|
@@ -281,21 +311,69 @@ function ensureProfileAvatarColumns(db: BetterSqlite3.Database) {
|
|
|
281
311
|
}
|
|
282
312
|
}
|
|
283
313
|
|
|
314
|
+
function ensureAccountExternalUserIdColumn(db: BetterSqlite3.Database) {
|
|
315
|
+
const columnNames = getColumnNames(db, "accounts");
|
|
316
|
+
if (!columnNames.has("external_user_id")) {
|
|
317
|
+
db.exec("alter table accounts add column external_user_id text");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function ensureTweetCollectionsTable(db: BetterSqlite3.Database) {
|
|
322
|
+
db.exec(`
|
|
323
|
+
create table if not exists tweet_collections (
|
|
324
|
+
account_id text not null,
|
|
325
|
+
tweet_id text not null,
|
|
326
|
+
kind text not null,
|
|
327
|
+
collected_at text,
|
|
328
|
+
source text not null,
|
|
329
|
+
raw_json text not null default '{}',
|
|
330
|
+
updated_at text not null,
|
|
331
|
+
primary key (account_id, tweet_id, kind)
|
|
332
|
+
);
|
|
333
|
+
`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function backfillTweetCollections(db: BetterSqlite3.Database) {
|
|
337
|
+
const now = new Date().toISOString();
|
|
338
|
+
const insert = db.prepare(`
|
|
339
|
+
insert or ignore into tweet_collections (
|
|
340
|
+
account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
|
|
341
|
+
)
|
|
342
|
+
select account_id, id, ?, null, 'legacy', '{}', ?
|
|
343
|
+
from tweets
|
|
344
|
+
where
|
|
345
|
+
case
|
|
346
|
+
when ? = 'likes' then liked
|
|
347
|
+
else bookmarked
|
|
348
|
+
end = 1
|
|
349
|
+
`);
|
|
350
|
+
|
|
351
|
+
db.transaction(() => {
|
|
352
|
+
insert.run("likes", now, "likes");
|
|
353
|
+
insert.run("bookmarks", now, "bookmarks");
|
|
354
|
+
})();
|
|
355
|
+
}
|
|
356
|
+
|
|
284
357
|
function ensureSchemaIndexes(db: BetterSqlite3.Database) {
|
|
285
358
|
db.exec(INDEX_SQL);
|
|
286
359
|
}
|
|
287
360
|
|
|
288
|
-
function initDatabase() {
|
|
361
|
+
function initDatabase(options: InitDatabaseOptions = {}) {
|
|
289
362
|
ensureBirdclawDirs();
|
|
290
363
|
|
|
291
364
|
if (!nativeDb) {
|
|
292
365
|
const { dbPath } = getBirdclawPaths();
|
|
293
366
|
nativeDb = new BetterSqlite3(dbPath);
|
|
294
367
|
nativeDb.exec(BASE_SCHEMA_SQL);
|
|
368
|
+
ensureAccountExternalUserIdColumn(nativeDb);
|
|
295
369
|
ensureTweetMetadataColumns(nativeDb);
|
|
296
370
|
ensureProfileAvatarColumns(nativeDb);
|
|
371
|
+
ensureTweetCollectionsTable(nativeDb);
|
|
297
372
|
ensureSchemaIndexes(nativeDb);
|
|
298
|
-
seedDemoData
|
|
373
|
+
if (options.seedDemoData !== false) {
|
|
374
|
+
seedDemoData(nativeDb);
|
|
375
|
+
}
|
|
376
|
+
backfillTweetCollections(nativeDb);
|
|
299
377
|
}
|
|
300
378
|
|
|
301
379
|
if (!kyselyDb) {
|
|
@@ -307,8 +385,8 @@ function initDatabase() {
|
|
|
307
385
|
}
|
|
308
386
|
}
|
|
309
387
|
|
|
310
|
-
export function getNativeDb() {
|
|
311
|
-
initDatabase();
|
|
388
|
+
export function getNativeDb(options: InitDatabaseOptions = {}) {
|
|
389
|
+
initDatabase(options);
|
|
312
390
|
return nativeDb as BetterSqlite3.Database;
|
|
313
391
|
}
|
|
314
392
|
|
package/src/lib/mentions-live.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
TweetEntities,
|
|
11
11
|
XurlMentionData,
|
|
12
12
|
XurlMentionsResponse,
|
|
13
|
+
XurlMentionUser,
|
|
13
14
|
} from "./types";
|
|
14
15
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
15
16
|
import { listMentionsViaXurl, lookupUsersByHandles } from "./xurl";
|
|
@@ -257,11 +258,7 @@ function mergeMentionPayloads(
|
|
|
257
258
|
): XurlMentionsResponse {
|
|
258
259
|
const tweets: XurlMentionData[] = [];
|
|
259
260
|
const seenTweetIds = new Set<string>();
|
|
260
|
-
const users:
|
|
261
|
-
? T extends Array<infer U>
|
|
262
|
-
? U[]
|
|
263
|
-
: never
|
|
264
|
-
: never = [];
|
|
261
|
+
const users: XurlMentionUser[] = [];
|
|
265
262
|
const seenUserIds = new Set<string>();
|
|
266
263
|
|
|
267
264
|
for (const page of pages) {
|
|
@@ -106,7 +106,7 @@ export async function resolveProfile(
|
|
|
106
106
|
return local;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
let user:
|
|
109
|
+
let user: XurlMentionUser | undefined;
|
|
110
110
|
let lastError: unknown;
|
|
111
111
|
|
|
112
112
|
try {
|
|
@@ -146,18 +146,18 @@ export async function resolveProfile(
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
if (local) {
|
|
149
|
-
return upsertProfileFromXUser(db, user
|
|
149
|
+
return upsertProfileFromXUser(db, user);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
const username = String(user.username ?? "").replace(/^@/, "");
|
|
153
153
|
if (username) {
|
|
154
154
|
const localByHandle = resolveLocalProfile(db, username);
|
|
155
155
|
if (localByHandle) {
|
|
156
|
-
return upsertProfileFromXUser(db, user
|
|
156
|
+
return upsertProfileFromXUser(db, user);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
return upsertProfileFromXUser(db, user
|
|
160
|
+
return upsertProfileFromXUser(db, user);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
export async function getAuthenticatedUserId() {
|
package/src/lib/mutes-write.ts
CHANGED
|
@@ -20,7 +20,7 @@ export async function addMute(
|
|
|
20
20
|
const transport = await runModerationAction({
|
|
21
21
|
action: "mute",
|
|
22
22
|
query: actionQuery,
|
|
23
|
-
targetUserId: resolved.externalUserId,
|
|
23
|
+
targetUserId: resolved.externalUserId ?? undefined,
|
|
24
24
|
transport: options.transport,
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -92,7 +92,7 @@ export async function removeMute(
|
|
|
92
92
|
const transport = await runModerationAction({
|
|
93
93
|
action: "unmute",
|
|
94
94
|
query: actionQuery,
|
|
95
|
-
targetUserId: resolved.externalUserId,
|
|
95
|
+
targetUserId: resolved.externalUserId ?? undefined,
|
|
96
96
|
transport: options.transport,
|
|
97
97
|
});
|
|
98
98
|
|