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/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 BirdMentionMedia {
13
+ interface BirdTweetMedia {
13
14
  type?: string;
14
15
  url?: string;
15
16
  }
16
17
 
17
- interface BirdMentionAuthor {
18
+ interface BirdTweetAuthor {
18
19
  username?: string;
19
20
  name?: string;
20
21
  }
21
22
 
22
- interface BirdMentionItem {
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?: BirdMentionAuthor;
32
+ author?: BirdTweetAuthor;
32
33
  authorId?: string;
33
- media?: BirdMentionMedia[];
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 toMediaEntities(media: BirdMentionMedia[] | undefined) {
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 normalizeBirdMentions(items: BirdMentionItem[]): XurlMentionsResponse {
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(birdCommand, [
146
- "mentions",
147
- "-n",
148
- String(maxResults),
149
- "--json",
150
- ]);
151
- const payload = JSON.parse(stdout) as unknown;
152
- if (!Array.isArray(payload)) {
153
- throw new Error("bird mentions returned unexpected JSON");
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 normalizeBirdMentions(payload as BirdMentionItem[]);
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(birdCommand, [
166
- "dms",
167
- "-n",
168
- String(maxResults),
169
- "--json",
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" ||
@@ -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 as XurlMentionUser);
270
+ const resolved = upsertProfileFromXUser(db, user);
276
271
  remoteProfileIds.push(resolved.profile.id);
277
272
  upsertRemoteBlock(
278
273
  db,