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
|
@@ -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
|
+
}
|