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
package/src/lib/bird.ts
CHANGED
|
@@ -8,18 +8,19 @@ import type {
|
|
|
8
8
|
} from "./types";
|
|
9
9
|
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
const BIRD_JSON_MAX_BUFFER_BYTES = 512 * 1024 * 1024;
|
|
11
12
|
|
|
12
|
-
interface
|
|
13
|
+
interface BirdTweetMedia {
|
|
13
14
|
type?: string;
|
|
14
15
|
url?: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
interface
|
|
18
|
+
interface BirdTweetAuthor {
|
|
18
19
|
username?: string;
|
|
19
20
|
name?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
interface
|
|
23
|
+
interface BirdTweetItem {
|
|
23
24
|
id: string;
|
|
24
25
|
text: string;
|
|
25
26
|
createdAt: string;
|
|
@@ -28,9 +29,9 @@ interface BirdMentionItem {
|
|
|
28
29
|
likeCount?: number;
|
|
29
30
|
conversationId?: string;
|
|
30
31
|
inReplyToStatusId?: string;
|
|
31
|
-
author?:
|
|
32
|
+
author?: BirdTweetAuthor;
|
|
32
33
|
authorId?: string;
|
|
33
|
-
media?:
|
|
34
|
+
media?: BirdTweetMedia[];
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface BirdDmUser {
|
|
@@ -73,7 +74,89 @@ function toIsoTimestamp(value: string) {
|
|
|
73
74
|
return parsed.toISOString();
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function
|
|
77
|
+
function escapeJsonStringControlChars(value: string) {
|
|
78
|
+
let output = "";
|
|
79
|
+
let inString = false;
|
|
80
|
+
let escaped = false;
|
|
81
|
+
|
|
82
|
+
for (const character of value) {
|
|
83
|
+
if (!inString) {
|
|
84
|
+
output += character;
|
|
85
|
+
if (character === '"') {
|
|
86
|
+
inString = true;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (escaped) {
|
|
92
|
+
output += character;
|
|
93
|
+
escaped = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (character === "\\") {
|
|
98
|
+
output += character;
|
|
99
|
+
escaped = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (character === '"') {
|
|
104
|
+
output += character;
|
|
105
|
+
inString = false;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (character === "\n") {
|
|
110
|
+
output += "\\n";
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (character === "\r") {
|
|
114
|
+
output += "\\r";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (character === "\t") {
|
|
118
|
+
output += "\\t";
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (character.charCodeAt(0) < 0x20) {
|
|
122
|
+
output += `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
output += character;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return output;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseBirdJson(stdout: string) {
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(stdout) as unknown;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!(error instanceof SyntaxError)) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
return JSON.parse(escapeJsonStringControlChars(stdout)) as unknown;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getBirdTweetItems(payload: unknown, command: string) {
|
|
144
|
+
if (Array.isArray(payload)) {
|
|
145
|
+
return payload as BirdTweetItem[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
payload &&
|
|
150
|
+
typeof payload === "object" &&
|
|
151
|
+
Array.isArray((payload as { tweets?: unknown }).tweets)
|
|
152
|
+
) {
|
|
153
|
+
return (payload as { tweets: BirdTweetItem[] }).tweets;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
throw new Error(`bird ${command} returned unexpected JSON`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toMediaEntities(media: BirdTweetMedia[] | undefined) {
|
|
77
160
|
if (!Array.isArray(media) || media.length === 0) {
|
|
78
161
|
return undefined;
|
|
79
162
|
}
|
|
@@ -92,7 +175,7 @@ function toMediaEntities(media: BirdMentionMedia[] | undefined) {
|
|
|
92
175
|
};
|
|
93
176
|
}
|
|
94
177
|
|
|
95
|
-
function
|
|
178
|
+
function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
|
|
96
179
|
const users = new Map<string, XurlMentionUser>();
|
|
97
180
|
const data = items.map((item): XurlMentionData => {
|
|
98
181
|
const authorId = String(
|
|
@@ -142,18 +225,75 @@ export async function listMentionsViaBird({
|
|
|
142
225
|
maxResults: number;
|
|
143
226
|
}): Promise<XurlMentionsResponse> {
|
|
144
227
|
const birdCommand = getBirdCommand();
|
|
145
|
-
const { stdout } = await execFileAsync(
|
|
146
|
-
|
|
147
|
-
"-n",
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
228
|
+
const { stdout } = await execFileAsync(
|
|
229
|
+
birdCommand,
|
|
230
|
+
["mentions", "-n", String(maxResults), "--json"],
|
|
231
|
+
{ maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
|
|
232
|
+
);
|
|
233
|
+
const payload = parseBirdJson(stdout);
|
|
234
|
+
|
|
235
|
+
return normalizeBirdTweets(getBirdTweetItems(payload, "mentions"));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function listTweetsViaBirdCommand({
|
|
239
|
+
command,
|
|
240
|
+
maxResults,
|
|
241
|
+
all,
|
|
242
|
+
maxPages,
|
|
243
|
+
}: {
|
|
244
|
+
command: "likes" | "bookmarks";
|
|
245
|
+
maxResults: number;
|
|
246
|
+
all?: boolean;
|
|
247
|
+
maxPages?: number;
|
|
248
|
+
}): Promise<XurlMentionsResponse> {
|
|
249
|
+
const birdCommand = getBirdCommand();
|
|
250
|
+
const args = [command, "-n", String(maxResults), "--json"];
|
|
251
|
+
if (all) {
|
|
252
|
+
args.push("--all");
|
|
154
253
|
}
|
|
254
|
+
if (maxPages !== undefined) {
|
|
255
|
+
args.push("--max-pages", String(maxPages));
|
|
256
|
+
}
|
|
257
|
+
const { stdout } = await execFileAsync(birdCommand, args, {
|
|
258
|
+
maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES,
|
|
259
|
+
});
|
|
260
|
+
const payload = parseBirdJson(stdout);
|
|
155
261
|
|
|
156
|
-
return
|
|
262
|
+
return normalizeBirdTweets(getBirdTweetItems(payload, command));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function listLikedTweetsViaBird({
|
|
266
|
+
maxResults,
|
|
267
|
+
all,
|
|
268
|
+
maxPages,
|
|
269
|
+
}: {
|
|
270
|
+
maxResults: number;
|
|
271
|
+
all?: boolean;
|
|
272
|
+
maxPages?: number;
|
|
273
|
+
}): Promise<XurlMentionsResponse> {
|
|
274
|
+
return listTweetsViaBirdCommand({
|
|
275
|
+
command: "likes",
|
|
276
|
+
maxResults,
|
|
277
|
+
all,
|
|
278
|
+
maxPages,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function listBookmarkedTweetsViaBird({
|
|
283
|
+
maxResults,
|
|
284
|
+
all,
|
|
285
|
+
maxPages,
|
|
286
|
+
}: {
|
|
287
|
+
maxResults: number;
|
|
288
|
+
all?: boolean;
|
|
289
|
+
maxPages?: number;
|
|
290
|
+
}): Promise<XurlMentionsResponse> {
|
|
291
|
+
return listTweetsViaBirdCommand({
|
|
292
|
+
command: "bookmarks",
|
|
293
|
+
maxResults,
|
|
294
|
+
all,
|
|
295
|
+
maxPages,
|
|
296
|
+
});
|
|
157
297
|
}
|
|
158
298
|
|
|
159
299
|
export async function listDirectMessagesViaBird({
|
|
@@ -162,13 +302,12 @@ export async function listDirectMessagesViaBird({
|
|
|
162
302
|
maxResults: number;
|
|
163
303
|
}): Promise<BirdDmsResponse> {
|
|
164
304
|
const birdCommand = getBirdCommand();
|
|
165
|
-
const { stdout } = await execFileAsync(
|
|
166
|
-
|
|
167
|
-
"-n",
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const payload = JSON.parse(stdout) as unknown;
|
|
305
|
+
const { stdout } = await execFileAsync(
|
|
306
|
+
birdCommand,
|
|
307
|
+
["dms", "-n", String(maxResults), "--json"],
|
|
308
|
+
{ maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
|
|
309
|
+
);
|
|
310
|
+
const payload = parseBirdJson(stdout);
|
|
172
311
|
if (
|
|
173
312
|
!payload ||
|
|
174
313
|
typeof payload !== "object" ||
|
package/src/lib/blocks-write.ts
CHANGED
|
@@ -20,7 +20,7 @@ export async function addBlock(
|
|
|
20
20
|
const transport = await runModerationAction({
|
|
21
21
|
action: "block",
|
|
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 removeBlock(
|
|
|
92
92
|
const transport = await runModerationAction({
|
|
93
93
|
action: "unblock",
|
|
94
94
|
query: actionQuery,
|
|
95
|
-
targetUserId: resolved.externalUserId,
|
|
95
|
+
targetUserId: resolved.externalUserId ?? undefined,
|
|
96
96
|
transport: options.transport,
|
|
97
97
|
});
|
|
98
98
|
|
package/src/lib/blocks.ts
CHANGED
|
@@ -5,12 +5,7 @@ import {
|
|
|
5
5
|
getDefaultAccountId,
|
|
6
6
|
toProfile,
|
|
7
7
|
} from "./moderation-target";
|
|
8
|
-
import type {
|
|
9
|
-
BlockItem,
|
|
10
|
-
BlockListResponse,
|
|
11
|
-
BlockSearchItem,
|
|
12
|
-
XurlMentionUser,
|
|
13
|
-
} from "./types";
|
|
8
|
+
import type { BlockItem, BlockListResponse, BlockSearchItem } from "./types";
|
|
14
9
|
import { upsertProfileFromXUser } from "./x-profile";
|
|
15
10
|
import { listBlockedUsers, lookupAuthenticatedUser } from "./xurl";
|
|
16
11
|
|
|
@@ -272,7 +267,7 @@ export async function syncBlocks(accountId: string) {
|
|
|
272
267
|
const page = await listBlockedUsers(sourceUserId, nextToken ?? undefined);
|
|
273
268
|
const mergeRemotePage = db.transaction(() => {
|
|
274
269
|
for (const user of page.items) {
|
|
275
|
-
const resolved = upsertProfileFromXUser(db, user
|
|
270
|
+
const resolved = upsertProfileFromXUser(db, user);
|
|
276
271
|
remoteProfileIds.push(resolved.profile.id);
|
|
277
272
|
upsertRemoteBlock(
|
|
278
273
|
db,
|