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.
@@ -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("&", "&amp;")
290
+ .replaceAll("<", "&lt;")
291
+ .replaceAll(">", "&gt;");
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(nativeDb);
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
 
@@ -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: XurlMentionsResponse["includes"] extends { users?: infer T }
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: Record<string, unknown> | undefined;
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 as XurlMentionUser);
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 as XurlMentionUser);
156
+ return upsertProfileFromXUser(db, user);
157
157
  }
158
158
  }
159
159
 
160
- return upsertProfileFromXUser(db, user as XurlMentionUser);
160
+ return upsertProfileFromXUser(db, user);
161
161
  }
162
162
 
163
163
  export async function getAuthenticatedUserId() {
@@ -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