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.
Files changed (74) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +389 -0
  4. package/bin/birdclaw.mjs +28 -0
  5. package/package.json +100 -0
  6. package/playwright.config.ts +31 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +25 -0
  11. package/public/robots.txt +3 -0
  12. package/src/cli-moderation.ts +210 -0
  13. package/src/cli.ts +450 -0
  14. package/src/components/AppNav.tsx +49 -0
  15. package/src/components/AvatarChip.tsx +47 -0
  16. package/src/components/DmWorkspace.tsx +246 -0
  17. package/src/components/EmbeddedTweetCard.tsx +44 -0
  18. package/src/components/InboxCard.tsx +136 -0
  19. package/src/components/ProfilePreview.tsx +55 -0
  20. package/src/components/ThemeSlider.tsx +124 -0
  21. package/src/components/TimelineCard.tsx +118 -0
  22. package/src/components/TweetMediaGrid.tsx +39 -0
  23. package/src/components/TweetRichText.tsx +85 -0
  24. package/src/lib/actions-transport.ts +173 -0
  25. package/src/lib/archive-finder.ts +128 -0
  26. package/src/lib/archive-import.ts +736 -0
  27. package/src/lib/avatar-cache.ts +184 -0
  28. package/src/lib/bird-actions.ts +200 -0
  29. package/src/lib/bird.ts +183 -0
  30. package/src/lib/blocklist.ts +119 -0
  31. package/src/lib/blocks-write.ts +118 -0
  32. package/src/lib/blocks.ts +326 -0
  33. package/src/lib/config.ts +152 -0
  34. package/src/lib/db.ts +326 -0
  35. package/src/lib/dms-live.ts +279 -0
  36. package/src/lib/inbox.ts +210 -0
  37. package/src/lib/mentions-export.ts +147 -0
  38. package/src/lib/mentions-live.ts +475 -0
  39. package/src/lib/moderation-target.ts +171 -0
  40. package/src/lib/moderation-write.ts +72 -0
  41. package/src/lib/mutes-write.ts +118 -0
  42. package/src/lib/mutes.ts +77 -0
  43. package/src/lib/openai.ts +86 -0
  44. package/src/lib/present.ts +20 -0
  45. package/src/lib/profile-hydration.ts +144 -0
  46. package/src/lib/profile-replies.ts +60 -0
  47. package/src/lib/queries.ts +725 -0
  48. package/src/lib/seed.ts +486 -0
  49. package/src/lib/sync-cache.ts +65 -0
  50. package/src/lib/theme-transition.ts +117 -0
  51. package/src/lib/theme.tsx +157 -0
  52. package/src/lib/tweet-render.ts +115 -0
  53. package/src/lib/types.ts +316 -0
  54. package/src/lib/ui.ts +270 -0
  55. package/src/lib/x-profile.ts +203 -0
  56. package/src/lib/x-web.ts +168 -0
  57. package/src/lib/xurl.ts +492 -0
  58. package/src/routeTree.gen.ts +282 -0
  59. package/src/router.tsx +20 -0
  60. package/src/routes/__root.tsx +64 -0
  61. package/src/routes/api/action.tsx +88 -0
  62. package/src/routes/api/avatar.tsx +41 -0
  63. package/src/routes/api/blocks.tsx +32 -0
  64. package/src/routes/api/inbox.tsx +35 -0
  65. package/src/routes/api/query.tsx +72 -0
  66. package/src/routes/api/status.tsx +15 -0
  67. package/src/routes/blocks.tsx +352 -0
  68. package/src/routes/dms.tsx +262 -0
  69. package/src/routes/inbox.tsx +201 -0
  70. package/src/routes/index.tsx +125 -0
  71. package/src/routes/mentions.tsx +125 -0
  72. package/src/styles.css +109 -0
  73. package/tsconfig.json +30 -0
  74. package/vite.config.ts +23 -0
@@ -0,0 +1,475 @@
1
+ import type Database from "better-sqlite3";
2
+ import { listMentionsViaBird } from "./bird";
3
+ import type { MentionsDataSource } from "./config";
4
+ import { getNativeDb } from "./db";
5
+ import { serializeMentionItemsAsXurlCompatible } from "./mentions-export";
6
+ import { listTimelineItems } from "./queries";
7
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
8
+ import type {
9
+ ReplyFilter,
10
+ TweetEntities,
11
+ XurlMentionData,
12
+ XurlMentionsResponse,
13
+ } from "./types";
14
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
15
+ import { listMentionsViaXurl, lookupUsersByHandles } from "./xurl";
16
+
17
+ export const DEFAULT_MENTIONS_CACHE_TTL_MS = 2 * 60_000;
18
+ const MIN_XURL_MENTIONS_LIMIT = 5;
19
+ const MAX_XURL_MENTIONS_LIMIT = 100;
20
+
21
+ function getMentionsFetchModeKey({
22
+ mode,
23
+ accountId,
24
+ pageSize,
25
+ all,
26
+ maxPages,
27
+ }: {
28
+ mode: MentionsDataSource;
29
+ accountId: string;
30
+ pageSize: number;
31
+ all: boolean;
32
+ maxPages: number | null;
33
+ }) {
34
+ return `mentions:${mode}:${accountId}:${String(pageSize)}:${all ? "all" : "single"}:${maxPages === null ? "all-pages" : String(maxPages)}`;
35
+ }
36
+
37
+ function parseCacheTtlMs(value?: number) {
38
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
39
+ return DEFAULT_MENTIONS_CACHE_TTL_MS;
40
+ }
41
+ return Math.floor(value);
42
+ }
43
+
44
+ function assertXurlLimit(limit: number) {
45
+ if (limit < MIN_XURL_MENTIONS_LIMIT || limit > MAX_XURL_MENTIONS_LIMIT) {
46
+ throw new Error("xurl mode requires --limit between 5 and 100");
47
+ }
48
+ }
49
+
50
+ function assertBirdLimit(limit: number) {
51
+ if (!Number.isFinite(limit) || limit < 1) {
52
+ throw new Error("bird mode requires --limit of at least 1");
53
+ }
54
+ }
55
+
56
+ function parseMaxPages(value?: number) {
57
+ if (value === undefined) {
58
+ return null;
59
+ }
60
+ if (!Number.isFinite(value) || value < 1) {
61
+ throw new Error("--max-pages must be at least 1");
62
+ }
63
+ return Math.floor(value);
64
+ }
65
+
66
+ function resolveAccount(db: Database.Database, accountId?: string) {
67
+ const row = accountId
68
+ ? (db
69
+ .prepare("select id, handle from accounts where id = ?")
70
+ .get(accountId) as { id: string; handle: string } | undefined)
71
+ : (db
72
+ .prepare(
73
+ `
74
+ select id, handle
75
+ from accounts
76
+ order by is_default desc, created_at asc
77
+ limit 1
78
+ `,
79
+ )
80
+ .get() as { id: string; handle: string } | undefined);
81
+
82
+ if (!row) {
83
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
84
+ }
85
+
86
+ return {
87
+ accountId: row.id,
88
+ username: row.handle.replace(/^@/, ""),
89
+ };
90
+ }
91
+
92
+ function toLocalEntities(tweet: XurlMentionData): TweetEntities {
93
+ const raw = tweet.entities;
94
+ if (!raw || typeof raw !== "object") {
95
+ return {};
96
+ }
97
+
98
+ const entities = raw as Record<string, unknown>;
99
+ const rawMentions = Array.isArray(entities.mentions) ? entities.mentions : [];
100
+ const rawUrls = Array.isArray(entities.urls) ? entities.urls : [];
101
+
102
+ return {
103
+ ...(rawMentions.length
104
+ ? {
105
+ mentions: rawMentions.map((mention) => {
106
+ const value =
107
+ mention && typeof mention === "object"
108
+ ? (mention as Record<string, unknown>)
109
+ : {};
110
+ return {
111
+ username: String(value.username ?? ""),
112
+ id: typeof value.id === "string" ? String(value.id) : undefined,
113
+ start: Number(value.start ?? 0),
114
+ end: Number(value.end ?? 0),
115
+ };
116
+ }),
117
+ }
118
+ : {}),
119
+ ...(rawUrls.length
120
+ ? {
121
+ urls: rawUrls.map((url) => {
122
+ const value =
123
+ url && typeof url === "object"
124
+ ? (url as Record<string, unknown>)
125
+ : {};
126
+ return {
127
+ url: String(value.url ?? ""),
128
+ expandedUrl: String(value.expanded_url ?? value.url ?? ""),
129
+ displayUrl: String(
130
+ value.display_url ?? value.expanded_url ?? value.url ?? "",
131
+ ),
132
+ start: Number(value.start ?? 0),
133
+ end: Number(value.end ?? 0),
134
+ };
135
+ }),
136
+ }
137
+ : {}),
138
+ };
139
+ }
140
+
141
+ function getMediaCount(tweet: XurlMentionData) {
142
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
143
+ return urls.filter(
144
+ (url) =>
145
+ url &&
146
+ typeof url === "object" &&
147
+ typeof (url as Record<string, unknown>).media_key === "string",
148
+ ).length;
149
+ }
150
+
151
+ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
152
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
153
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
154
+ tweetId,
155
+ text,
156
+ );
157
+ }
158
+
159
+ function mergeMentionsIntoLocalStore(
160
+ db: Database.Database,
161
+ accountId: string,
162
+ payload: XurlMentionsResponse,
163
+ ) {
164
+ const usersById = new Map(
165
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
166
+ );
167
+ const upsertTweet = db.prepare(
168
+ `
169
+ insert into tweets (
170
+ id, account_id, author_profile_id, kind, text, created_at,
171
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
172
+ entities_json, media_json, quoted_tweet_id
173
+ ) values (?, ?, ?, 'mention', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
174
+ on conflict(id) do update set
175
+ account_id = excluded.account_id,
176
+ author_profile_id = excluded.author_profile_id,
177
+ kind = excluded.kind,
178
+ text = excluded.text,
179
+ created_at = excluded.created_at,
180
+ like_count = excluded.like_count,
181
+ media_count = excluded.media_count,
182
+ entities_json = excluded.entities_json,
183
+ media_json = excluded.media_json,
184
+ is_replied = max(tweets.is_replied, excluded.is_replied),
185
+ bookmarked = max(tweets.bookmarked, excluded.bookmarked),
186
+ liked = max(tweets.liked, excluded.liked)
187
+ `,
188
+ );
189
+
190
+ const writePayload = db.transaction(() => {
191
+ for (const tweet of payload.data) {
192
+ const author =
193
+ usersById.get(tweet.author_id) ??
194
+ ({
195
+ id: tweet.author_id,
196
+ username: `user_${tweet.author_id}`,
197
+ name: `user_${tweet.author_id}`,
198
+ } as const);
199
+ const profile = usersById.has(tweet.author_id)
200
+ ? upsertProfileFromXUser(db, author)
201
+ : ensureStubProfileForXUser(db, tweet.author_id);
202
+ upsertTweet.run(
203
+ tweet.id,
204
+ accountId,
205
+ profile.profile.id,
206
+ tweet.text,
207
+ tweet.created_at,
208
+ Number(tweet.public_metrics?.like_count ?? 0),
209
+ getMediaCount(tweet),
210
+ JSON.stringify(toLocalEntities(tweet)),
211
+ );
212
+ replaceTweetFts(db, tweet.id, tweet.text);
213
+ }
214
+ });
215
+
216
+ writePayload();
217
+ }
218
+
219
+ function shouldReturnFilteredLocalPayload({
220
+ search,
221
+ replyFilter,
222
+ }: {
223
+ search?: string;
224
+ replyFilter?: ReplyFilter;
225
+ }) {
226
+ return (
227
+ Boolean(search?.trim()) ||
228
+ replyFilter === "replied" ||
229
+ replyFilter === "unreplied"
230
+ );
231
+ }
232
+
233
+ function readLocalXurlCompatiblePayload({
234
+ accountId,
235
+ search,
236
+ replyFilter,
237
+ limit,
238
+ }: {
239
+ accountId?: string;
240
+ search?: string;
241
+ replyFilter?: ReplyFilter;
242
+ limit: number;
243
+ }) {
244
+ return serializeMentionItemsAsXurlCompatible(
245
+ listTimelineItems({
246
+ resource: "mentions",
247
+ account: accountId,
248
+ search,
249
+ replyFilter,
250
+ limit,
251
+ }),
252
+ );
253
+ }
254
+
255
+ function mergeMentionPayloads(
256
+ pages: XurlMentionsResponse[],
257
+ ): XurlMentionsResponse {
258
+ const tweets: XurlMentionData[] = [];
259
+ 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 = [];
265
+ const seenUserIds = new Set<string>();
266
+
267
+ for (const page of pages) {
268
+ for (const tweet of page.data) {
269
+ if (seenTweetIds.has(tweet.id)) {
270
+ continue;
271
+ }
272
+ seenTweetIds.add(tweet.id);
273
+ tweets.push(tweet);
274
+ }
275
+
276
+ for (const user of page.includes?.users ?? []) {
277
+ if (seenUserIds.has(user.id)) {
278
+ continue;
279
+ }
280
+ seenUserIds.add(user.id);
281
+ users.push(user);
282
+ }
283
+ }
284
+
285
+ const lastMeta = pages.at(-1)?.meta;
286
+ return {
287
+ data: tweets,
288
+ includes: users.length > 0 ? { users } : undefined,
289
+ meta: {
290
+ ...lastMeta,
291
+ result_count: tweets.length,
292
+ page_count: pages.length,
293
+ next_token:
294
+ typeof lastMeta?.next_token === "string" ? lastMeta.next_token : null,
295
+ },
296
+ };
297
+ }
298
+
299
+ async function fetchMentionsViaXurl({
300
+ resolvedAccount,
301
+ limit,
302
+ all,
303
+ parsedMaxPages,
304
+ }: {
305
+ resolvedAccount: ReturnType<typeof resolveAccount>;
306
+ limit: number;
307
+ all: boolean;
308
+ parsedMaxPages: number | null;
309
+ }) {
310
+ const [accountUser] = await lookupUsersByHandles([resolvedAccount.username]);
311
+ if (!accountUser?.id) {
312
+ throw new Error(
313
+ `Could not resolve X user id for @${resolvedAccount.username}`,
314
+ );
315
+ }
316
+
317
+ const pages: XurlMentionsResponse[] = [];
318
+ let nextToken: string | undefined;
319
+ let pageCount = 0;
320
+ do {
321
+ const payload = await listMentionsViaXurl({
322
+ maxResults: limit,
323
+ username: resolvedAccount.username,
324
+ userId: String(accountUser.id),
325
+ paginationToken: nextToken,
326
+ });
327
+ pages.push(payload);
328
+ const metaNextToken =
329
+ typeof payload.meta?.next_token === "string"
330
+ ? payload.meta.next_token
331
+ : undefined;
332
+ nextToken = metaNextToken;
333
+ pageCount += 1;
334
+ } while (
335
+ all &&
336
+ nextToken &&
337
+ (parsedMaxPages === null || pageCount < parsedMaxPages)
338
+ );
339
+
340
+ return mergeMentionPayloads(pages);
341
+ }
342
+
343
+ async function exportMentionsViaCachedLiveSource({
344
+ mode,
345
+ account,
346
+ search,
347
+ replyFilter = "all",
348
+ limit = 20,
349
+ all = false,
350
+ maxPages,
351
+ refresh = false,
352
+ cacheTtlMs,
353
+ }: {
354
+ mode: MentionsDataSource;
355
+ account?: string;
356
+ search?: string;
357
+ replyFilter?: ReplyFilter;
358
+ limit?: number;
359
+ all?: boolean;
360
+ maxPages?: number;
361
+ refresh?: boolean;
362
+ cacheTtlMs?: number;
363
+ }) {
364
+ if (mode === "xurl") {
365
+ assertXurlLimit(limit);
366
+ } else {
367
+ assertBirdLimit(limit);
368
+ }
369
+ const parsedMaxPages = parseMaxPages(maxPages);
370
+ const fetchAll = mode === "xurl" && (all || parsedMaxPages !== null);
371
+
372
+ const db = getNativeDb();
373
+ const resolvedAccount = resolveAccount(db, account);
374
+ const cacheKey = getMentionsFetchModeKey({
375
+ mode,
376
+ accountId: resolvedAccount.accountId,
377
+ pageSize: limit,
378
+ all: fetchAll,
379
+ maxPages: parsedMaxPages,
380
+ });
381
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
382
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
383
+ const cacheAgeMs = cached
384
+ ? Date.now() - new Date(cached.updatedAt).getTime()
385
+ : Number.POSITIVE_INFINITY;
386
+
387
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
388
+ if (
389
+ shouldReturnFilteredLocalPayload({
390
+ search,
391
+ replyFilter,
392
+ })
393
+ ) {
394
+ return readLocalXurlCompatiblePayload({
395
+ accountId: resolvedAccount.accountId,
396
+ search,
397
+ replyFilter,
398
+ limit: fetchAll ? cached.value.data.length : limit,
399
+ });
400
+ }
401
+ return cached.value;
402
+ }
403
+
404
+ try {
405
+ const payload =
406
+ mode === "bird"
407
+ ? await listMentionsViaBird({ maxResults: limit })
408
+ : await fetchMentionsViaXurl({
409
+ resolvedAccount,
410
+ limit,
411
+ all: fetchAll,
412
+ parsedMaxPages,
413
+ });
414
+ mergeMentionsIntoLocalStore(db, resolvedAccount.accountId, payload);
415
+ writeSyncCache(cacheKey, payload, db);
416
+
417
+ if (
418
+ shouldReturnFilteredLocalPayload({
419
+ search,
420
+ replyFilter,
421
+ })
422
+ ) {
423
+ return readLocalXurlCompatiblePayload({
424
+ accountId: resolvedAccount.accountId,
425
+ search,
426
+ replyFilter,
427
+ limit: fetchAll ? payload.data.length : limit,
428
+ });
429
+ }
430
+
431
+ return payload;
432
+ } catch (error) {
433
+ if (!refresh && cached) {
434
+ if (
435
+ shouldReturnFilteredLocalPayload({
436
+ search,
437
+ replyFilter,
438
+ })
439
+ ) {
440
+ return readLocalXurlCompatiblePayload({
441
+ accountId: resolvedAccount.accountId,
442
+ search,
443
+ replyFilter,
444
+ limit: fetchAll ? cached.value.data.length : limit,
445
+ });
446
+ }
447
+ return cached.value;
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+
453
+ export async function exportMentionsViaCachedXurl(
454
+ options: Omit<
455
+ Parameters<typeof exportMentionsViaCachedLiveSource>[0],
456
+ "mode"
457
+ >,
458
+ ) {
459
+ return exportMentionsViaCachedLiveSource({
460
+ ...options,
461
+ mode: "xurl",
462
+ });
463
+ }
464
+
465
+ export async function exportMentionsViaCachedBird(
466
+ options: Omit<
467
+ Parameters<typeof exportMentionsViaCachedLiveSource>[0],
468
+ "mode"
469
+ >,
470
+ ) {
471
+ return exportMentionsViaCachedLiveSource({
472
+ ...options,
473
+ mode: "bird",
474
+ });
475
+ }
@@ -0,0 +1,171 @@
1
+ import type Database from "better-sqlite3";
2
+ import { lookupProfileViaBird } from "./bird-actions";
3
+ import { getNativeDb } from "./db";
4
+ import type { ProfileRecord, XurlMentionUser } from "./types";
5
+ import { getExternalUserId, upsertProfileFromXUser } from "./x-profile";
6
+ import {
7
+ lookupAuthenticatedUser,
8
+ lookupUsersByHandles,
9
+ lookupUsersByIds,
10
+ } from "./xurl";
11
+
12
+ export interface ResolvedModerationProfile {
13
+ profile: ProfileRecord;
14
+ externalUserId: string | null;
15
+ }
16
+
17
+ export function toProfile(row: Record<string, unknown>): ProfileRecord {
18
+ return {
19
+ id: String(row.id),
20
+ handle: String(row.handle),
21
+ displayName: String(row.display_name),
22
+ bio: String(row.bio),
23
+ followersCount: Number(row.followers_count),
24
+ avatarHue: Number(row.avatar_hue),
25
+ avatarUrl:
26
+ typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
27
+ createdAt: String(row.created_at),
28
+ };
29
+ }
30
+
31
+ export function normalizeProfileQuery(value: string) {
32
+ const trimmed = value.trim();
33
+ if (!trimmed) return "";
34
+
35
+ const withoutPrefix = trimmed.replace(/^@/, "");
36
+ const urlMatch = withoutPrefix.match(
37
+ /^(?:https?:\/\/)?(?:www\.)?(?:x|twitter)\.com\/([^/?#]+)/i,
38
+ );
39
+ return (urlMatch?.[1] ?? withoutPrefix).replace(/^@/, "").trim();
40
+ }
41
+
42
+ export function getDefaultAccountId(db: Database.Database) {
43
+ const row = db
44
+ .prepare(
45
+ `
46
+ select id
47
+ from accounts
48
+ order by is_default desc, created_at asc
49
+ limit 1
50
+ `,
51
+ )
52
+ .get() as { id: string } | undefined;
53
+ return row?.id ?? "acct_primary";
54
+ }
55
+
56
+ export function getAccountHandle(db: Database.Database, accountId: string) {
57
+ const row = db
58
+ .prepare("select handle from accounts where id = ?")
59
+ .get(accountId) as { handle: string } | undefined;
60
+ return row?.handle.replace(/^@/, "") ?? "";
61
+ }
62
+
63
+ export function resolveLocalProfile(
64
+ db: Database.Database,
65
+ normalizedQuery: string,
66
+ ): ResolvedModerationProfile | null {
67
+ const row = db
68
+ .prepare(
69
+ `
70
+ select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
71
+ from profiles
72
+ where id = ? or handle = ?
73
+ limit 1
74
+ `,
75
+ )
76
+ .get(normalizedQuery, normalizedQuery) as
77
+ | Record<string, unknown>
78
+ | undefined;
79
+
80
+ if (!row) {
81
+ return null;
82
+ }
83
+
84
+ const profile = toProfile(row);
85
+ return {
86
+ profile,
87
+ externalUserId: getExternalUserId(profile.id),
88
+ };
89
+ }
90
+
91
+ export async function resolveProfile(
92
+ query: string,
93
+ ): Promise<ResolvedModerationProfile> {
94
+ const db = getNativeDb();
95
+ const normalizedQuery = normalizeProfileQuery(query);
96
+ if (!normalizedQuery) {
97
+ throw new Error("Missing profile handle or id");
98
+ }
99
+
100
+ const local = resolveLocalProfile(db, normalizedQuery);
101
+ if (
102
+ local &&
103
+ !local.profile.id.startsWith("profile_group_") &&
104
+ local.externalUserId
105
+ ) {
106
+ return local;
107
+ }
108
+
109
+ let user: Record<string, unknown> | undefined;
110
+ let lastError: unknown;
111
+
112
+ try {
113
+ user =
114
+ (await lookupProfileViaBird(local?.profile.handle ?? normalizedQuery)) ??
115
+ undefined;
116
+ } catch (error) {
117
+ lastError = error;
118
+ }
119
+
120
+ if (!user) {
121
+ try {
122
+ if (/^\d+$/.test(normalizedQuery)) {
123
+ [user] = await lookupUsersByIds([normalizedQuery]);
124
+ } else {
125
+ [user] = await lookupUsersByHandles([
126
+ local?.profile.handle ?? normalizedQuery,
127
+ ]);
128
+ }
129
+ } catch (error) {
130
+ lastError = error;
131
+ }
132
+ }
133
+
134
+ if (!user && lastError) {
135
+ if (local) {
136
+ return local;
137
+ }
138
+ throw lastError;
139
+ }
140
+
141
+ if (!user) {
142
+ if (local) {
143
+ return local;
144
+ }
145
+ throw new Error(`Profile not found: ${query}`);
146
+ }
147
+
148
+ if (local) {
149
+ return upsertProfileFromXUser(db, user as XurlMentionUser);
150
+ }
151
+
152
+ const username = String(user.username ?? "").replace(/^@/, "");
153
+ if (username) {
154
+ const localByHandle = resolveLocalProfile(db, username);
155
+ if (localByHandle) {
156
+ return upsertProfileFromXUser(db, user as XurlMentionUser);
157
+ }
158
+ }
159
+
160
+ return upsertProfileFromXUser(db, user as XurlMentionUser);
161
+ }
162
+
163
+ export async function getAuthenticatedUserId() {
164
+ try {
165
+ const me = await lookupAuthenticatedUser();
166
+ const id = me?.id;
167
+ return typeof id === "string" && id.length > 0 ? id : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
@@ -0,0 +1,72 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { ActionsTransport } from "./config";
3
+ import { getNativeDb } from "./db";
4
+ import {
5
+ getAccountHandle,
6
+ getDefaultAccountId,
7
+ normalizeProfileQuery,
8
+ resolveProfile,
9
+ } from "./moderation-target";
10
+
11
+ export interface ModerationActionOptions {
12
+ transport?: ActionsTransport;
13
+ }
14
+
15
+ interface ResolveModerationTargetParams {
16
+ accountId: string;
17
+ query: string;
18
+ selfActionError: string;
19
+ }
20
+
21
+ export async function resolveModerationTarget({
22
+ accountId,
23
+ query,
24
+ selfActionError,
25
+ }: ResolveModerationTargetParams) {
26
+ const db = getNativeDb();
27
+ const resolvedAccountId = accountId || getDefaultAccountId(db);
28
+ const accountHandle = getAccountHandle(db, resolvedAccountId);
29
+ if (normalizeProfileQuery(query) === accountHandle) {
30
+ throw new Error(selfActionError);
31
+ }
32
+
33
+ const resolved = await resolveProfile(query);
34
+ return {
35
+ db,
36
+ resolved,
37
+ resolvedAccountId,
38
+ actionQuery:
39
+ resolved.externalUserId ??
40
+ resolved.profile.handle ??
41
+ normalizeProfileQuery(query),
42
+ };
43
+ }
44
+
45
+ export function writeModerationRow(
46
+ db: Database.Database,
47
+ table: "blocks" | "mutes",
48
+ accountId: string,
49
+ profileId: string,
50
+ createdAt: string,
51
+ ) {
52
+ db.prepare(
53
+ `
54
+ insert into ${table} (account_id, profile_id, source, created_at)
55
+ values (?, ?, 'manual', ?)
56
+ on conflict(account_id, profile_id) do update set
57
+ source = excluded.source,
58
+ created_at = excluded.created_at
59
+ `,
60
+ ).run(accountId, profileId, createdAt);
61
+ }
62
+
63
+ export function deleteModerationRow(
64
+ db: Database.Database,
65
+ table: "blocks" | "mutes",
66
+ accountId: string,
67
+ profileId: string,
68
+ ) {
69
+ db.prepare(
70
+ `delete from ${table} where account_id = ? and profile_id = ?`,
71
+ ).run(accountId, profileId);
72
+ }