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,118 @@
1
+ import { runModerationAction } from "./actions-transport";
2
+ import {
3
+ deleteModerationRow,
4
+ type ModerationActionOptions,
5
+ resolveModerationTarget,
6
+ writeModerationRow,
7
+ } from "./moderation-write";
8
+
9
+ export async function addBlock(
10
+ accountId: string,
11
+ query: string,
12
+ options: ModerationActionOptions = {},
13
+ ) {
14
+ const { db, resolved, resolvedAccountId, actionQuery } =
15
+ await resolveModerationTarget({
16
+ accountId,
17
+ query,
18
+ selfActionError: "Cannot block the current account",
19
+ });
20
+ const transport = await runModerationAction({
21
+ action: "block",
22
+ query: actionQuery,
23
+ targetUserId: resolved.externalUserId,
24
+ transport: options.transport,
25
+ });
26
+
27
+ if (!transport.ok) {
28
+ return {
29
+ ok: false,
30
+ action: "block",
31
+ accountId: resolvedAccountId,
32
+ profile: resolved.profile,
33
+ transport,
34
+ };
35
+ }
36
+
37
+ const blockedAt = new Date().toISOString();
38
+ writeModerationRow(
39
+ db,
40
+ "blocks",
41
+ resolvedAccountId,
42
+ resolved.profile.id,
43
+ blockedAt,
44
+ );
45
+
46
+ return {
47
+ ok: true,
48
+ action: "block",
49
+ accountId: resolvedAccountId,
50
+ blockedAt,
51
+ profile: resolved.profile,
52
+ transport,
53
+ };
54
+ }
55
+
56
+ export async function recordBlock(accountId: string, query: string) {
57
+ const { db, resolved, resolvedAccountId } = await resolveModerationTarget({
58
+ accountId,
59
+ query,
60
+ selfActionError: "Cannot block the current account",
61
+ });
62
+
63
+ const blockedAt = new Date().toISOString();
64
+ writeModerationRow(
65
+ db,
66
+ "blocks",
67
+ resolvedAccountId,
68
+ resolved.profile.id,
69
+ blockedAt,
70
+ );
71
+
72
+ return {
73
+ ok: true,
74
+ action: "record-block",
75
+ accountId: resolvedAccountId,
76
+ blockedAt,
77
+ profile: resolved.profile,
78
+ };
79
+ }
80
+
81
+ export async function removeBlock(
82
+ accountId: string,
83
+ query: string,
84
+ options: ModerationActionOptions = {},
85
+ ) {
86
+ const { db, resolved, resolvedAccountId, actionQuery } =
87
+ await resolveModerationTarget({
88
+ accountId,
89
+ query,
90
+ selfActionError: "Cannot block the current account",
91
+ });
92
+ const transport = await runModerationAction({
93
+ action: "unblock",
94
+ query: actionQuery,
95
+ targetUserId: resolved.externalUserId,
96
+ transport: options.transport,
97
+ });
98
+
99
+ if (!transport.ok) {
100
+ return {
101
+ ok: false,
102
+ action: "unblock",
103
+ accountId: resolvedAccountId,
104
+ profile: resolved.profile,
105
+ transport,
106
+ };
107
+ }
108
+
109
+ deleteModerationRow(db, "blocks", resolvedAccountId, resolved.profile.id);
110
+
111
+ return {
112
+ ok: true,
113
+ action: "unblock",
114
+ accountId: resolvedAccountId,
115
+ profile: resolved.profile,
116
+ transport,
117
+ };
118
+ }
@@ -0,0 +1,326 @@
1
+ import type Database from "better-sqlite3";
2
+ import { getNativeDb } from "./db";
3
+ import {
4
+ getAccountHandle,
5
+ getDefaultAccountId,
6
+ toProfile,
7
+ } from "./moderation-target";
8
+ import type {
9
+ BlockItem,
10
+ BlockListResponse,
11
+ BlockSearchItem,
12
+ XurlMentionUser,
13
+ } from "./types";
14
+ import { upsertProfileFromXUser } from "./x-profile";
15
+ import { listBlockedUsers, lookupAuthenticatedUser } from "./xurl";
16
+
17
+ export { addBlock, recordBlock, removeBlock } from "./blocks-write";
18
+
19
+ function remoteBlockSyncDisabled() {
20
+ return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
21
+ }
22
+
23
+ function upsertRemoteBlock(
24
+ db: Database.Database,
25
+ accountId: string,
26
+ profileId: string,
27
+ blockedAt: string,
28
+ ) {
29
+ db.prepare(
30
+ `
31
+ insert into blocks (account_id, profile_id, source, created_at)
32
+ values (?, ?, 'remote', ?)
33
+ on conflict(account_id, profile_id) do update set
34
+ source = excluded.source,
35
+ created_at = blocks.created_at
36
+ `,
37
+ ).run(accountId, profileId, blockedAt);
38
+ }
39
+
40
+ function pruneRemoteBlocks(
41
+ db: Database.Database,
42
+ accountId: string,
43
+ profileIds: string[],
44
+ ) {
45
+ if (profileIds.length === 0) {
46
+ db.prepare(
47
+ "delete from blocks where account_id = ? and source = 'remote'",
48
+ ).run(accountId);
49
+ return;
50
+ }
51
+
52
+ const placeholders = profileIds.map(() => "?").join(", ");
53
+ db.prepare(
54
+ `
55
+ delete from blocks
56
+ where account_id = ?
57
+ and source = 'remote'
58
+ and profile_id not in (${placeholders})
59
+ `,
60
+ ).run(accountId, ...profileIds);
61
+ }
62
+
63
+ export function listBlocks({
64
+ account,
65
+ search,
66
+ limit = 50,
67
+ }: {
68
+ account?: string;
69
+ search?: string;
70
+ limit?: number;
71
+ } = {}): BlockItem[] {
72
+ const db = getNativeDb();
73
+ const params: Array<string | number> = [];
74
+ let where = "where 1 = 1";
75
+
76
+ if (account && account !== "all") {
77
+ where += " and b.account_id = ?";
78
+ params.push(account);
79
+ }
80
+
81
+ if (search?.trim()) {
82
+ where += " and (p.handle like ? or p.display_name like ? or p.bio like ?)";
83
+ params.push(
84
+ `%${search.trim()}%`,
85
+ `%${search.trim()}%`,
86
+ `%${search.trim()}%`,
87
+ );
88
+ }
89
+
90
+ params.push(limit);
91
+
92
+ const rows = db
93
+ .prepare(
94
+ `
95
+ select
96
+ b.account_id,
97
+ a.handle as account_handle,
98
+ b.source,
99
+ b.created_at as blocked_at,
100
+ p.id,
101
+ p.handle,
102
+ p.display_name,
103
+ p.bio,
104
+ p.followers_count,
105
+ p.avatar_hue,
106
+ p.avatar_url,
107
+ p.created_at
108
+ from blocks b
109
+ join accounts a on a.id = b.account_id
110
+ join profiles p on p.id = b.profile_id
111
+ ${where}
112
+ order by b.created_at desc
113
+ limit ?
114
+ `,
115
+ )
116
+ .all(...params) as Array<Record<string, unknown>>;
117
+
118
+ return rows.map((row) => ({
119
+ accountId: String(row.account_id),
120
+ accountHandle: String(row.account_handle),
121
+ source: String(row.source),
122
+ blockedAt: String(row.blocked_at),
123
+ profile: toProfile(row),
124
+ }));
125
+ }
126
+
127
+ export function searchBlockCandidates({
128
+ accountId,
129
+ search,
130
+ limit = 8,
131
+ }: {
132
+ accountId: string;
133
+ search?: string;
134
+ limit?: number;
135
+ }): BlockSearchItem[] {
136
+ const db = getNativeDb();
137
+ if (!search?.trim()) {
138
+ return [];
139
+ }
140
+
141
+ const accountHandle = getAccountHandle(db, accountId);
142
+ const rows = db
143
+ .prepare(
144
+ `
145
+ select
146
+ p.id,
147
+ p.handle,
148
+ p.display_name,
149
+ p.bio,
150
+ p.followers_count,
151
+ p.avatar_hue,
152
+ p.avatar_url,
153
+ p.created_at,
154
+ b.created_at as blocked_at
155
+ from profiles p
156
+ left join blocks b
157
+ on b.profile_id = p.id
158
+ and b.account_id = ?
159
+ where p.id != 'profile_me'
160
+ and p.handle != ?
161
+ and (
162
+ p.handle like ?
163
+ or p.display_name like ?
164
+ or p.bio like ?
165
+ )
166
+ order by
167
+ case when b.created_at is null then 1 else 0 end,
168
+ b.created_at desc,
169
+ p.followers_count desc,
170
+ p.display_name asc
171
+ limit ?
172
+ `,
173
+ )
174
+ .all(
175
+ accountId,
176
+ accountHandle,
177
+ `%${search.trim()}%`,
178
+ `%${search.trim()}%`,
179
+ `%${search.trim()}%`,
180
+ limit,
181
+ ) as Array<Record<string, unknown>>;
182
+
183
+ return rows.map((row) => ({
184
+ profile: toProfile(row),
185
+ isBlocked: Boolean(row.blocked_at),
186
+ blockedAt:
187
+ typeof row.blocked_at === "string" ? String(row.blocked_at) : undefined,
188
+ }));
189
+ }
190
+
191
+ export function getBlocksResponse({
192
+ accountId,
193
+ search,
194
+ limit,
195
+ }: {
196
+ accountId?: string;
197
+ search?: string;
198
+ limit?: number;
199
+ }): BlockListResponse {
200
+ const db = getNativeDb();
201
+ const resolvedAccountId =
202
+ accountId && accountId !== "all" ? accountId : getDefaultAccountId(db);
203
+
204
+ return {
205
+ items: listBlocks({ account: accountId, search, limit }),
206
+ matches: searchBlockCandidates({
207
+ accountId: resolvedAccountId,
208
+ search,
209
+ limit: Math.min(limit ?? 8, 12),
210
+ }),
211
+ };
212
+ }
213
+
214
+ export async function syncBlocks(accountId: string) {
215
+ const db = getNativeDb();
216
+ const resolvedAccountId = accountId || getDefaultAccountId(db);
217
+ const blockedAt = new Date().toISOString();
218
+ const remoteProfileIds: string[] = [];
219
+
220
+ if (remoteBlockSyncDisabled()) {
221
+ return {
222
+ ok: true,
223
+ accountId: resolvedAccountId,
224
+ synced: false,
225
+ syncedCount: 0,
226
+ transport: {
227
+ ok: true,
228
+ output: "remote block sync disabled in test mode",
229
+ },
230
+ };
231
+ }
232
+
233
+ try {
234
+ const me = await lookupAuthenticatedUser();
235
+ const sourceUserId =
236
+ typeof me?.id === "string" && me.id.length > 0 ? me.id : null;
237
+ const sourceUsername =
238
+ typeof me?.username === "string" ? me.username.replace(/^@/, "") : "";
239
+ const accountHandle = getAccountHandle(db, resolvedAccountId);
240
+
241
+ if (!sourceUserId) {
242
+ return {
243
+ ok: false,
244
+ accountId: resolvedAccountId,
245
+ synced: false,
246
+ syncedCount: 0,
247
+ transport: {
248
+ ok: false,
249
+ output:
250
+ "xurl block sync unavailable without an authenticated account",
251
+ },
252
+ };
253
+ }
254
+
255
+ if (accountHandle && sourceUsername && accountHandle !== sourceUsername) {
256
+ return {
257
+ ok: false,
258
+ accountId: resolvedAccountId,
259
+ synced: false,
260
+ syncedCount: 0,
261
+ transport: {
262
+ ok: false,
263
+ output: `xurl is authenticated as @${sourceUsername}, not @${accountHandle}`,
264
+ },
265
+ };
266
+ }
267
+
268
+ let nextToken: string | null = null;
269
+ let pageCount = 0;
270
+
271
+ do {
272
+ const page = await listBlockedUsers(sourceUserId, nextToken ?? undefined);
273
+ const mergeRemotePage = db.transaction(() => {
274
+ for (const user of page.items) {
275
+ const resolved = upsertProfileFromXUser(db, user as XurlMentionUser);
276
+ remoteProfileIds.push(resolved.profile.id);
277
+ upsertRemoteBlock(
278
+ db,
279
+ resolvedAccountId,
280
+ resolved.profile.id,
281
+ blockedAt,
282
+ );
283
+ }
284
+ });
285
+ mergeRemotePage();
286
+ nextToken = page.nextToken;
287
+ pageCount += 1;
288
+ } while (nextToken && pageCount < 20);
289
+
290
+ const pruneMergedBlocks = db.transaction(() => {
291
+ pruneRemoteBlocks(db, resolvedAccountId, remoteProfileIds);
292
+ });
293
+ pruneMergedBlocks();
294
+
295
+ return {
296
+ ok: true,
297
+ accountId: resolvedAccountId,
298
+ synced: true,
299
+ syncedCount: remoteProfileIds.length,
300
+ transport: {
301
+ ok: true,
302
+ output: `synced ${remoteProfileIds.length} remote blocks`,
303
+ },
304
+ };
305
+ } catch (error) {
306
+ return {
307
+ ok: false,
308
+ accountId: resolvedAccountId,
309
+ synced: remoteProfileIds.length > 0,
310
+ syncedCount: remoteProfileIds.length,
311
+ transport: {
312
+ ok: false,
313
+ output:
314
+ remoteProfileIds.length > 0
315
+ ? `partial block sync after ${remoteProfileIds.length} profiles: ${
316
+ error instanceof Error
317
+ ? error.message
318
+ : "xurl block sync failed"
319
+ }`
320
+ : error instanceof Error
321
+ ? error.message
322
+ : "xurl block sync failed",
323
+ },
324
+ };
325
+ }
326
+ }
@@ -0,0 +1,152 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export interface BirdclawPaths {
6
+ rootDir: string;
7
+ dbPath: string;
8
+ mediaOriginalsDir: string;
9
+ mediaThumbsDir: string;
10
+ configPath: string;
11
+ }
12
+
13
+ export type MentionsDataSource = "birdclaw" | "xurl" | "bird";
14
+ export type ActionsTransport = "auto" | "bird" | "xurl";
15
+
16
+ export interface BirdclawConfig {
17
+ mentions?: {
18
+ dataSource?: MentionsDataSource;
19
+ birdCommand?: string;
20
+ };
21
+ actions?: {
22
+ transport?: ActionsTransport;
23
+ };
24
+ }
25
+
26
+ let cachedPaths: BirdclawPaths | undefined;
27
+ let cachedConfig: BirdclawConfig | undefined;
28
+
29
+ export function getBirdclawPaths(): BirdclawPaths {
30
+ if (cachedPaths) {
31
+ return cachedPaths;
32
+ }
33
+
34
+ const rootDir =
35
+ process.env.BIRDCLAW_HOME?.trim() || path.join(os.homedir(), ".birdclaw");
36
+
37
+ cachedPaths = {
38
+ rootDir,
39
+ dbPath: path.join(rootDir, "birdclaw.sqlite"),
40
+ mediaOriginalsDir: path.join(rootDir, "media", "originals"),
41
+ mediaThumbsDir: path.join(rootDir, "media", "thumbs"),
42
+ configPath: path.join(rootDir, "config.json"),
43
+ };
44
+
45
+ return cachedPaths;
46
+ }
47
+
48
+ function parseConfigFile(configPath: string): BirdclawConfig {
49
+ if (!existsSync(configPath)) {
50
+ return {};
51
+ }
52
+
53
+ const raw = readFileSync(configPath, "utf8").trim();
54
+ if (!raw) {
55
+ return {};
56
+ }
57
+
58
+ const parsed = JSON.parse(raw) as BirdclawConfig;
59
+ return parsed && typeof parsed === "object" ? parsed : {};
60
+ }
61
+
62
+ export function getBirdclawConfig(): BirdclawConfig {
63
+ if (cachedConfig) {
64
+ return cachedConfig;
65
+ }
66
+
67
+ const configPath =
68
+ process.env.BIRDCLAW_CONFIG?.trim() || getBirdclawPaths().configPath;
69
+ cachedConfig = parseConfigFile(configPath);
70
+ return cachedConfig;
71
+ }
72
+
73
+ export function resolveMentionsDataSource(
74
+ requestedMode?: string,
75
+ ): MentionsDataSource {
76
+ if (
77
+ requestedMode === "birdclaw" ||
78
+ requestedMode === "xurl" ||
79
+ requestedMode === "bird"
80
+ ) {
81
+ return requestedMode;
82
+ }
83
+
84
+ const envMode = process.env.BIRDCLAW_MENTIONS_DATA_SOURCE?.trim();
85
+ if (envMode === "birdclaw" || envMode === "xurl" || envMode === "bird") {
86
+ return envMode;
87
+ }
88
+
89
+ const configMode = getBirdclawConfig().mentions?.dataSource;
90
+ if (
91
+ configMode === "birdclaw" ||
92
+ configMode === "xurl" ||
93
+ configMode === "bird"
94
+ ) {
95
+ return configMode;
96
+ }
97
+
98
+ return "birdclaw";
99
+ }
100
+
101
+ export function resolveActionsTransport(
102
+ requestedMode?: string,
103
+ ): ActionsTransport {
104
+ if (
105
+ requestedMode === "auto" ||
106
+ requestedMode === "bird" ||
107
+ requestedMode === "xurl"
108
+ ) {
109
+ return requestedMode;
110
+ }
111
+
112
+ const envMode = process.env.BIRDCLAW_ACTIONS_TRANSPORT?.trim();
113
+ if (envMode === "auto" || envMode === "bird" || envMode === "xurl") {
114
+ return envMode;
115
+ }
116
+
117
+ const configMode = getBirdclawConfig().actions?.transport;
118
+ if (configMode === "auto" || configMode === "bird" || configMode === "xurl") {
119
+ return configMode;
120
+ }
121
+
122
+ return "auto";
123
+ }
124
+
125
+ export function getBirdCommand() {
126
+ const envCommand = process.env.BIRDCLAW_BIRD_COMMAND?.trim();
127
+ if (envCommand) {
128
+ return envCommand;
129
+ }
130
+
131
+ const configuredCommand = getBirdclawConfig().mentions?.birdCommand?.trim();
132
+ if (configuredCommand) {
133
+ return configuredCommand;
134
+ }
135
+
136
+ return path.join(os.homedir(), "Projects", "bird", "bird");
137
+ }
138
+
139
+ export function ensureBirdclawDirs(): BirdclawPaths {
140
+ const paths = getBirdclawPaths();
141
+
142
+ mkdirSync(paths.rootDir, { recursive: true });
143
+ mkdirSync(paths.mediaOriginalsDir, { recursive: true });
144
+ mkdirSync(paths.mediaThumbsDir, { recursive: true });
145
+
146
+ return paths;
147
+ }
148
+
149
+ export function resetBirdclawPathsForTests() {
150
+ cachedPaths = undefined;
151
+ cachedConfig = undefined;
152
+ }