birdclaw 0.2.0 → 0.3.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 +34 -4
- package/README.md +26 -15
- package/package.json +12 -11
- package/playwright.config.ts +5 -2
- package/src/cli.ts +118 -5
- package/src/components/AppNav.tsx +1 -1
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +9 -2
- package/src/lib/backup.ts +5 -3
- package/src/lib/bird-actions.ts +4 -0
- package/src/lib/bird.ts +143 -17
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +8 -0
- package/src/lib/mention-threads-live.ts +271 -0
- package/src/lib/mentions-live.ts +1 -1
- package/src/lib/moderation-target.ts +3 -1
- package/src/lib/openai.ts +1 -1
- package/src/lib/profile-hydration.ts +8 -0
- package/src/lib/profile-replies.ts +1 -1
- package/src/lib/queries.ts +135 -15
- package/src/lib/research.ts +596 -0
- package/src/lib/seed.ts +8 -2
- package/src/lib/timeline-collections-live.ts +16 -2
- package/src/lib/timeline-live.ts +175 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +29 -1
- package/src/lib/x-profile.ts +41 -12
- package/src/lib/xurl.ts +39 -5
- package/src/routes/blocks.tsx +1 -1
package/src/lib/bird.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { promisify } from "node:util";
|
|
3
6
|
import { getBirdCommand } from "./config";
|
|
4
7
|
import type {
|
|
5
8
|
XurlMentionData,
|
|
6
9
|
XurlMentionsResponse,
|
|
7
10
|
XurlMentionUser,
|
|
11
|
+
XurlReferencedTweet,
|
|
12
|
+
XurlTweetsResponse,
|
|
8
13
|
} from "./types";
|
|
9
14
|
|
|
10
15
|
const execFileAsync = promisify(execFile);
|
|
@@ -28,7 +33,9 @@ interface BirdTweetItem {
|
|
|
28
33
|
retweetCount?: number;
|
|
29
34
|
likeCount?: number;
|
|
30
35
|
conversationId?: string;
|
|
31
|
-
inReplyToStatusId?: string;
|
|
36
|
+
inReplyToStatusId?: string | null;
|
|
37
|
+
quotedStatusId?: string | null;
|
|
38
|
+
quotedTweet?: { id?: string | null } | null;
|
|
32
39
|
author?: BirdTweetAuthor;
|
|
33
40
|
authorId?: string;
|
|
34
41
|
media?: BirdTweetMedia[];
|
|
@@ -140,6 +147,39 @@ function parseBirdJson(stdout: string) {
|
|
|
140
147
|
}
|
|
141
148
|
}
|
|
142
149
|
|
|
150
|
+
function formatBirdCommandError(error: unknown, birdCommand: string) {
|
|
151
|
+
if (
|
|
152
|
+
error instanceof Error &&
|
|
153
|
+
"code" in error &&
|
|
154
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
155
|
+
) {
|
|
156
|
+
return new Error(
|
|
157
|
+
`bird CLI not found at ${birdCommand}. Install @steipete/bird or set BIRDCLAW_BIRD_COMMAND / mentions.birdCommand to a valid bird binary.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return error;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runBirdJsonCommand(args: string[], timeoutMs?: number) {
|
|
165
|
+
const tempDir = mkdtempSync(join(tmpdir(), "birdclaw-bird-"));
|
|
166
|
+
const stdoutPath = join(tempDir, "stdout.json");
|
|
167
|
+
const birdCommand = getBirdCommand();
|
|
168
|
+
try {
|
|
169
|
+
const shellScript = 'out="$1"; shift; exec "$@" > "$out"';
|
|
170
|
+
await execFileAsync(
|
|
171
|
+
"/bin/bash",
|
|
172
|
+
["-lc", shellScript, "birdclaw-bird", stdoutPath, birdCommand, ...args],
|
|
173
|
+
{ maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES, timeout: timeoutMs },
|
|
174
|
+
);
|
|
175
|
+
return readFileSync(stdoutPath, "utf8");
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw formatBirdCommandError(error, birdCommand);
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
143
183
|
function getBirdTweetItems(payload: unknown, command: string) {
|
|
144
184
|
if (Array.isArray(payload)) {
|
|
145
185
|
return payload as BirdTweetItem[];
|
|
@@ -156,6 +196,17 @@ function getBirdTweetItems(payload: unknown, command: string) {
|
|
|
156
196
|
throw new Error(`bird ${command} returned unexpected JSON`);
|
|
157
197
|
}
|
|
158
198
|
|
|
199
|
+
function getBirdTweetItem(payload: unknown, command: string) {
|
|
200
|
+
if (payload && typeof payload === "object") {
|
|
201
|
+
const record = payload as { id?: unknown };
|
|
202
|
+
if (typeof record.id === "string" && record.id.length > 0) {
|
|
203
|
+
return payload as BirdTweetItem;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
throw new Error(`bird ${command} returned unexpected JSON`);
|
|
208
|
+
}
|
|
209
|
+
|
|
159
210
|
function toMediaEntities(media: BirdTweetMedia[] | undefined) {
|
|
160
211
|
if (!Array.isArray(media) || media.length === 0) {
|
|
161
212
|
return undefined;
|
|
@@ -175,6 +226,25 @@ function toMediaEntities(media: BirdTweetMedia[] | undefined) {
|
|
|
175
226
|
};
|
|
176
227
|
}
|
|
177
228
|
|
|
229
|
+
function toReferencedTweets(item: BirdTweetItem) {
|
|
230
|
+
const references: XurlReferencedTweet[] = [];
|
|
231
|
+
if (typeof item.inReplyToStatusId === "string" && item.inReplyToStatusId) {
|
|
232
|
+
references.push({ type: "replied_to", id: item.inReplyToStatusId });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const quotedTweetId =
|
|
236
|
+
typeof item.quotedStatusId === "string" && item.quotedStatusId
|
|
237
|
+
? item.quotedStatusId
|
|
238
|
+
: typeof item.quotedTweet?.id === "string" && item.quotedTweet.id
|
|
239
|
+
? item.quotedTweet.id
|
|
240
|
+
: null;
|
|
241
|
+
if (quotedTweetId) {
|
|
242
|
+
references.push({ type: "quoted", id: quotedTweetId });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return references.length > 0 ? references : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
178
248
|
function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
|
|
179
249
|
const users = new Map<string, XurlMentionUser>();
|
|
180
250
|
const data = items.map((item): XurlMentionData => {
|
|
@@ -196,6 +266,7 @@ function normalizeBirdTweets(items: BirdTweetItem[]): XurlMentionsResponse {
|
|
|
196
266
|
created_at: toIsoTimestamp(item.createdAt),
|
|
197
267
|
conversation_id: item.conversationId ?? item.id,
|
|
198
268
|
entities: toMediaEntities(item.media),
|
|
269
|
+
referenced_tweets: toReferencedTweets(item),
|
|
199
270
|
public_metrics: {
|
|
200
271
|
reply_count: Number(item.replyCount ?? 0),
|
|
201
272
|
retweet_count: Number(item.retweetCount ?? 0),
|
|
@@ -224,12 +295,12 @@ export async function listMentionsViaBird({
|
|
|
224
295
|
}: {
|
|
225
296
|
maxResults: number;
|
|
226
297
|
}): Promise<XurlMentionsResponse> {
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
298
|
+
const stdout = await runBirdJsonCommand([
|
|
299
|
+
"mentions",
|
|
300
|
+
"-n",
|
|
301
|
+
String(maxResults),
|
|
302
|
+
"--json",
|
|
303
|
+
]);
|
|
233
304
|
const payload = parseBirdJson(stdout);
|
|
234
305
|
|
|
235
306
|
return normalizeBirdTweets(getBirdTweetItems(payload, "mentions"));
|
|
@@ -246,7 +317,6 @@ async function listTweetsViaBirdCommand({
|
|
|
246
317
|
all?: boolean;
|
|
247
318
|
maxPages?: number;
|
|
248
319
|
}): Promise<XurlMentionsResponse> {
|
|
249
|
-
const birdCommand = getBirdCommand();
|
|
250
320
|
const args = [command, "-n", String(maxResults), "--json"];
|
|
251
321
|
if (all) {
|
|
252
322
|
args.push("--all");
|
|
@@ -254,9 +324,7 @@ async function listTweetsViaBirdCommand({
|
|
|
254
324
|
if (maxPages !== undefined) {
|
|
255
325
|
args.push("--max-pages", String(maxPages));
|
|
256
326
|
}
|
|
257
|
-
const
|
|
258
|
-
maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES,
|
|
259
|
-
});
|
|
327
|
+
const stdout = await runBirdJsonCommand(args);
|
|
260
328
|
const payload = parseBirdJson(stdout);
|
|
261
329
|
|
|
262
330
|
return normalizeBirdTweets(getBirdTweetItems(payload, command));
|
|
@@ -296,17 +364,75 @@ export async function listBookmarkedTweetsViaBird({
|
|
|
296
364
|
});
|
|
297
365
|
}
|
|
298
366
|
|
|
367
|
+
export async function lookupTweetsByIdsViaBird(
|
|
368
|
+
ids: string[],
|
|
369
|
+
): Promise<XurlTweetsResponse> {
|
|
370
|
+
if (ids.length === 0) {
|
|
371
|
+
return { data: [] };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const tweets = await Promise.all(
|
|
375
|
+
ids.map(async (id) => {
|
|
376
|
+
const stdout = await runBirdJsonCommand(["read", id, "--json"]);
|
|
377
|
+
return getBirdTweetItem(parseBirdJson(stdout), "read");
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
return normalizeBirdTweets(tweets);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export async function listHomeTimelineViaBird({
|
|
385
|
+
maxResults,
|
|
386
|
+
following = true,
|
|
387
|
+
}: {
|
|
388
|
+
maxResults: number;
|
|
389
|
+
following?: boolean;
|
|
390
|
+
}): Promise<XurlMentionsResponse> {
|
|
391
|
+
const args = ["home", "-n", String(maxResults), "--json"];
|
|
392
|
+
if (following) {
|
|
393
|
+
args.push("--following");
|
|
394
|
+
}
|
|
395
|
+
const stdout = await runBirdJsonCommand(args);
|
|
396
|
+
const payload = parseBirdJson(stdout);
|
|
397
|
+
|
|
398
|
+
return normalizeBirdTweets(getBirdTweetItems(payload, "home"));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function listThreadViaBird({
|
|
402
|
+
tweetId,
|
|
403
|
+
all,
|
|
404
|
+
maxPages,
|
|
405
|
+
timeoutMs,
|
|
406
|
+
}: {
|
|
407
|
+
tweetId: string;
|
|
408
|
+
all?: boolean;
|
|
409
|
+
maxPages?: number;
|
|
410
|
+
timeoutMs?: number;
|
|
411
|
+
}): Promise<XurlMentionsResponse> {
|
|
412
|
+
const args = ["thread", tweetId, "--json"];
|
|
413
|
+
if (all) {
|
|
414
|
+
args.push("--all");
|
|
415
|
+
}
|
|
416
|
+
if (maxPages !== undefined) {
|
|
417
|
+
args.push("--max-pages", String(maxPages));
|
|
418
|
+
}
|
|
419
|
+
const stdout = await runBirdJsonCommand(args, timeoutMs);
|
|
420
|
+
const payload = parseBirdJson(stdout);
|
|
421
|
+
|
|
422
|
+
return normalizeBirdTweets(getBirdTweetItems(payload, "thread"));
|
|
423
|
+
}
|
|
424
|
+
|
|
299
425
|
export async function listDirectMessagesViaBird({
|
|
300
426
|
maxResults,
|
|
301
427
|
}: {
|
|
302
428
|
maxResults: number;
|
|
303
429
|
}): Promise<BirdDmsResponse> {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
);
|
|
430
|
+
const stdout = await runBirdJsonCommand([
|
|
431
|
+
"dms",
|
|
432
|
+
"-n",
|
|
433
|
+
String(maxResults),
|
|
434
|
+
"--json",
|
|
435
|
+
]);
|
|
310
436
|
const payload = parseBirdJson(stdout);
|
|
311
437
|
if (
|
|
312
438
|
!payload ||
|
package/src/lib/config.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
} from "node:fs";
|
|
2
8
|
import os from "node:os";
|
|
3
9
|
import path from "node:path";
|
|
4
10
|
|
|
@@ -128,6 +134,28 @@ export function resolveActionsTransport(
|
|
|
128
134
|
return "auto";
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
function findCommandOnPath(command: string) {
|
|
138
|
+
const pathValue = process.env.PATH;
|
|
139
|
+
if (!pathValue) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
144
|
+
if (!directory) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const candidate = path.join(directory, command);
|
|
148
|
+
try {
|
|
149
|
+
accessSync(candidate, constants.X_OK);
|
|
150
|
+
return candidate;
|
|
151
|
+
} catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
131
159
|
export function getBirdCommand() {
|
|
132
160
|
const envCommand = process.env.BIRDCLAW_BIRD_COMMAND?.trim();
|
|
133
161
|
if (envCommand) {
|
|
@@ -139,6 +167,11 @@ export function getBirdCommand() {
|
|
|
139
167
|
return configuredCommand;
|
|
140
168
|
}
|
|
141
169
|
|
|
170
|
+
const pathCommand = findCommandOnPath("bird");
|
|
171
|
+
if (pathCommand) {
|
|
172
|
+
return pathCommand;
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
return path.join(os.homedir(), "Projects", "bird", "bird");
|
|
143
176
|
}
|
|
144
177
|
|
package/src/lib/db.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ProfilesTable {
|
|
|
19
19
|
display_name: string;
|
|
20
20
|
bio: string;
|
|
21
21
|
followers_count: number;
|
|
22
|
+
following_count: number;
|
|
22
23
|
avatar_hue: number;
|
|
23
24
|
avatar_url: string | null;
|
|
24
25
|
created_at: string;
|
|
@@ -154,6 +155,7 @@ const BASE_SCHEMA_SQL = `
|
|
|
154
155
|
display_name text not null,
|
|
155
156
|
bio text not null,
|
|
156
157
|
followers_count integer not null default 0,
|
|
158
|
+
following_count integer not null default 0,
|
|
157
159
|
avatar_hue integer not null default 0,
|
|
158
160
|
avatar_url text,
|
|
159
161
|
created_at text not null
|
|
@@ -271,6 +273,7 @@ const INDEX_SQL = `
|
|
|
271
273
|
create index if not exists idx_dm_conversations_account on dm_conversations(account_id, last_message_at desc);
|
|
272
274
|
create index if not exists idx_dm_messages_conversation on dm_messages(conversation_id, created_at asc);
|
|
273
275
|
create index if not exists idx_profiles_followers on profiles(followers_count desc);
|
|
276
|
+
create index if not exists idx_profiles_following on profiles(following_count desc);
|
|
274
277
|
create index if not exists idx_blocks_account_created on blocks(account_id, created_at desc);
|
|
275
278
|
create index if not exists idx_mutes_account_created on mutes(account_id, created_at desc);
|
|
276
279
|
create index if not exists idx_ai_scores_updated on ai_scores(updated_at desc);
|
|
@@ -306,6 +309,11 @@ function ensureTweetMetadataColumns(db: BetterSqlite3.Database) {
|
|
|
306
309
|
|
|
307
310
|
function ensureProfileAvatarColumns(db: BetterSqlite3.Database) {
|
|
308
311
|
const columnNames = getColumnNames(db, "profiles");
|
|
312
|
+
if (!columnNames.has("following_count")) {
|
|
313
|
+
db.exec(
|
|
314
|
+
"alter table profiles add column following_count integer not null default 0",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
309
317
|
if (!columnNames.has("avatar_url")) {
|
|
310
318
|
db.exec("alter table profiles add column avatar_url text");
|
|
311
319
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { listThreadViaBird } from "./bird";
|
|
3
|
+
import { getNativeDb } from "./db";
|
|
4
|
+
import type { XurlMentionData, XurlMentionsResponse } from "./types";
|
|
5
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 30;
|
|
8
|
+
const DEFAULT_DELAY_MS = 1500;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
10
|
+
|
|
11
|
+
function assertPositiveInteger(value: number, name: string) {
|
|
12
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
13
|
+
throw new Error(`${name} must be at least 1`);
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseNonNegativeInteger(value: number | undefined, name: string) {
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
23
|
+
throw new Error(`${name} must be non-negative`);
|
|
24
|
+
}
|
|
25
|
+
return Math.floor(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sleep(ms: number) {
|
|
29
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getMediaCount(tweet: XurlMentionData) {
|
|
33
|
+
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
34
|
+
return urls.filter(
|
|
35
|
+
(url) =>
|
|
36
|
+
url &&
|
|
37
|
+
typeof url === "object" &&
|
|
38
|
+
typeof (url as Record<string, unknown>).media_key === "string",
|
|
39
|
+
).length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
|
|
43
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
44
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
45
|
+
tweetId,
|
|
46
|
+
text,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveAccount(db: Database.Database, accountId?: string) {
|
|
51
|
+
const row = accountId
|
|
52
|
+
? (db
|
|
53
|
+
.prepare("select id, handle from accounts where id = ?")
|
|
54
|
+
.get(accountId) as { id: string; handle: string } | undefined)
|
|
55
|
+
: (db
|
|
56
|
+
.prepare(
|
|
57
|
+
`
|
|
58
|
+
select id, handle
|
|
59
|
+
from accounts
|
|
60
|
+
order by is_default desc, created_at asc
|
|
61
|
+
limit 1
|
|
62
|
+
`,
|
|
63
|
+
)
|
|
64
|
+
.get() as { id: string; handle: string } | undefined);
|
|
65
|
+
|
|
66
|
+
if (!row) {
|
|
67
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
accountId: row.id,
|
|
72
|
+
handle: row.handle.replace(/^@/, "").toLowerCase(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listRecentMentionIds(
|
|
77
|
+
db: Database.Database,
|
|
78
|
+
accountId: string,
|
|
79
|
+
limit: number,
|
|
80
|
+
) {
|
|
81
|
+
return (
|
|
82
|
+
db
|
|
83
|
+
.prepare(
|
|
84
|
+
`
|
|
85
|
+
select id
|
|
86
|
+
from tweets
|
|
87
|
+
where kind = 'mention' and account_id = ?
|
|
88
|
+
order by created_at desc
|
|
89
|
+
limit ?
|
|
90
|
+
`,
|
|
91
|
+
)
|
|
92
|
+
.all(accountId, limit) as Array<{ id: string }>
|
|
93
|
+
).map((row) => row.id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getReplyToId(tweet: XurlMentionData) {
|
|
97
|
+
return tweet.referenced_tweets?.find((entry) => entry.type === "replied_to")
|
|
98
|
+
?.id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mergeMentionThreadIntoLocalStore({
|
|
102
|
+
db,
|
|
103
|
+
accountId,
|
|
104
|
+
accountHandle,
|
|
105
|
+
mentionIds,
|
|
106
|
+
payload,
|
|
107
|
+
}: {
|
|
108
|
+
db: Database.Database;
|
|
109
|
+
accountId: string;
|
|
110
|
+
accountHandle: string;
|
|
111
|
+
mentionIds: Set<string>;
|
|
112
|
+
payload: XurlMentionsResponse;
|
|
113
|
+
}) {
|
|
114
|
+
const usersById = new Map(
|
|
115
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
116
|
+
);
|
|
117
|
+
const upsertTweet = db.prepare(
|
|
118
|
+
`
|
|
119
|
+
insert into tweets (
|
|
120
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
121
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
122
|
+
entities_json, media_json, quoted_tweet_id
|
|
123
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, '[]', null)
|
|
124
|
+
on conflict(id) do update set
|
|
125
|
+
account_id = excluded.account_id,
|
|
126
|
+
author_profile_id = excluded.author_profile_id,
|
|
127
|
+
kind = case
|
|
128
|
+
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
129
|
+
when excluded.kind in ('home', 'mention') then excluded.kind
|
|
130
|
+
else coalesce(nullif(tweets.kind, ''), excluded.kind)
|
|
131
|
+
end,
|
|
132
|
+
text = excluded.text,
|
|
133
|
+
created_at = excluded.created_at,
|
|
134
|
+
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
135
|
+
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
136
|
+
like_count = excluded.like_count,
|
|
137
|
+
media_count = excluded.media_count,
|
|
138
|
+
entities_json = excluded.entities_json,
|
|
139
|
+
media_json = excluded.media_json,
|
|
140
|
+
bookmarked = tweets.bookmarked,
|
|
141
|
+
liked = tweets.liked
|
|
142
|
+
`,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
db.transaction(() => {
|
|
146
|
+
for (const tweet of payload.data) {
|
|
147
|
+
const author =
|
|
148
|
+
usersById.get(tweet.author_id) ??
|
|
149
|
+
({
|
|
150
|
+
id: tweet.author_id,
|
|
151
|
+
username: `user_${tweet.author_id}`,
|
|
152
|
+
name: `user_${tweet.author_id}`,
|
|
153
|
+
} as const);
|
|
154
|
+
const profile = usersById.has(tweet.author_id)
|
|
155
|
+
? upsertProfileFromXUser(db, author)
|
|
156
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
157
|
+
const handle = author.username.toLowerCase();
|
|
158
|
+
const kind = mentionIds.has(tweet.id)
|
|
159
|
+
? "mention"
|
|
160
|
+
: handle === accountHandle
|
|
161
|
+
? "home"
|
|
162
|
+
: "thread";
|
|
163
|
+
const replyToId = getReplyToId(tweet);
|
|
164
|
+
upsertTweet.run(
|
|
165
|
+
tweet.id,
|
|
166
|
+
accountId,
|
|
167
|
+
profile.profile.id,
|
|
168
|
+
kind,
|
|
169
|
+
tweet.text,
|
|
170
|
+
tweet.created_at,
|
|
171
|
+
replyToId ? 1 : 0,
|
|
172
|
+
replyToId ?? null,
|
|
173
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
174
|
+
getMediaCount(tweet),
|
|
175
|
+
JSON.stringify(tweet.entities ?? {}),
|
|
176
|
+
);
|
|
177
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function syncMentionThreads({
|
|
183
|
+
account,
|
|
184
|
+
limit = DEFAULT_LIMIT,
|
|
185
|
+
delayMs = DEFAULT_DELAY_MS,
|
|
186
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
187
|
+
all = false,
|
|
188
|
+
maxPages,
|
|
189
|
+
}: {
|
|
190
|
+
account?: string;
|
|
191
|
+
limit?: number;
|
|
192
|
+
delayMs?: number;
|
|
193
|
+
timeoutMs?: number;
|
|
194
|
+
all?: boolean;
|
|
195
|
+
maxPages?: number;
|
|
196
|
+
}) {
|
|
197
|
+
const parsedLimit = assertPositiveInteger(limit, "--limit");
|
|
198
|
+
const parsedDelayMs = parseNonNegativeInteger(delayMs, "--delay-ms") ?? 0;
|
|
199
|
+
const parsedTimeoutMs = assertPositiveInteger(timeoutMs, "--timeout-ms");
|
|
200
|
+
const parsedMaxPages = parseNonNegativeInteger(maxPages, "--max-pages");
|
|
201
|
+
const db = getNativeDb();
|
|
202
|
+
const resolvedAccount = resolveAccount(db, account);
|
|
203
|
+
const mentionIds = listRecentMentionIds(
|
|
204
|
+
db,
|
|
205
|
+
resolvedAccount.accountId,
|
|
206
|
+
parsedLimit,
|
|
207
|
+
);
|
|
208
|
+
const mentionIdSet = new Set(mentionIds);
|
|
209
|
+
const results: Array<{
|
|
210
|
+
tweetId: string;
|
|
211
|
+
ok: boolean;
|
|
212
|
+
count: number;
|
|
213
|
+
error?: string;
|
|
214
|
+
}> = [];
|
|
215
|
+
let mergedTweets = 0;
|
|
216
|
+
const uniqueTweetIds = new Set<string>();
|
|
217
|
+
|
|
218
|
+
for (const [index, tweetId] of mentionIds.entries()) {
|
|
219
|
+
if (index > 0 && parsedDelayMs > 0) {
|
|
220
|
+
await sleep(parsedDelayMs);
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const payload = await listThreadViaBird({
|
|
224
|
+
tweetId,
|
|
225
|
+
all,
|
|
226
|
+
maxPages: parsedMaxPages,
|
|
227
|
+
timeoutMs: parsedTimeoutMs,
|
|
228
|
+
});
|
|
229
|
+
mergeMentionThreadIntoLocalStore({
|
|
230
|
+
db,
|
|
231
|
+
accountId: resolvedAccount.accountId,
|
|
232
|
+
accountHandle: resolvedAccount.handle,
|
|
233
|
+
mentionIds: mentionIdSet,
|
|
234
|
+
payload,
|
|
235
|
+
});
|
|
236
|
+
for (const tweet of payload.data) {
|
|
237
|
+
uniqueTweetIds.add(tweet.id);
|
|
238
|
+
}
|
|
239
|
+
mergedTweets += payload.data.length;
|
|
240
|
+
results.push({ tweetId, ok: true, count: payload.data.length });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
results.push({
|
|
243
|
+
tweetId,
|
|
244
|
+
ok: false,
|
|
245
|
+
count: 0,
|
|
246
|
+
error: error instanceof Error ? error.message : String(error),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const failures = results.filter((item) => !item.ok);
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
accountId: resolvedAccount.accountId,
|
|
255
|
+
mentions: mentionIds.length,
|
|
256
|
+
threads: results.length,
|
|
257
|
+
succeeded: results.length - failures.length,
|
|
258
|
+
failed: failures.length,
|
|
259
|
+
mergedTweets,
|
|
260
|
+
uniqueTweets: uniqueTweetIds.size,
|
|
261
|
+
options: {
|
|
262
|
+
limit: parsedLimit,
|
|
263
|
+
delayMs: parsedDelayMs,
|
|
264
|
+
timeoutMs: parsedTimeoutMs,
|
|
265
|
+
all,
|
|
266
|
+
maxPages: parsedMaxPages ?? null,
|
|
267
|
+
},
|
|
268
|
+
results,
|
|
269
|
+
failures,
|
|
270
|
+
};
|
|
271
|
+
}
|
package/src/lib/mentions-live.ts
CHANGED
|
@@ -307,7 +307,7 @@ async function fetchMentionsViaXurl({
|
|
|
307
307
|
const [accountUser] = await lookupUsersByHandles([resolvedAccount.username]);
|
|
308
308
|
if (!accountUser?.id) {
|
|
309
309
|
throw new Error(
|
|
310
|
-
`Could not resolve
|
|
310
|
+
`Could not resolve Twitter user id for @${resolvedAccount.username}`,
|
|
311
311
|
);
|
|
312
312
|
}
|
|
313
313
|
|
|
@@ -15,12 +15,14 @@ export interface ResolvedModerationProfile {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
18
|
+
const followingCount = Number(row.following_count ?? 0);
|
|
18
19
|
return {
|
|
19
20
|
id: String(row.id),
|
|
20
21
|
handle: String(row.handle),
|
|
21
22
|
displayName: String(row.display_name),
|
|
22
23
|
bio: String(row.bio),
|
|
23
24
|
followersCount: Number(row.followers_count),
|
|
25
|
+
...(Number.isFinite(followingCount) ? { followingCount } : {}),
|
|
24
26
|
avatarHue: Number(row.avatar_hue),
|
|
25
27
|
avatarUrl:
|
|
26
28
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
@@ -67,7 +69,7 @@ export function resolveLocalProfile(
|
|
|
67
69
|
const row = db
|
|
68
70
|
.prepare(
|
|
69
71
|
`
|
|
70
|
-
select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
|
|
72
|
+
select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
|
|
71
73
|
from profiles
|
|
72
74
|
where id = ? or handle = ?
|
|
73
75
|
limit 1
|
package/src/lib/openai.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function scoreInboxItemWithOpenAI(
|
|
|
44
44
|
{
|
|
45
45
|
role: "system",
|
|
46
46
|
content:
|
|
47
|
-
"You rank inbound
|
|
47
|
+
"You rank inbound Twitter mentions and DMs for Peter Steinberger. Return JSON only with keys score, summary, reasoning. Score 0-100. High score means worth replying soon. Prefer specific, actionable, novel, high-signal items. Penalize generic praise, low-context asks, and low-signal chatter. summary max 18 words. reasoning max 28 words.",
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
role: "user",
|
|
@@ -51,6 +51,7 @@ export async function hydrateProfilesFromX() {
|
|
|
51
51
|
display_name = ?,
|
|
52
52
|
bio = ?,
|
|
53
53
|
followers_count = ?,
|
|
54
|
+
following_count = coalesce(?, following_count),
|
|
54
55
|
avatar_url = coalesce(?, avatar_url),
|
|
55
56
|
created_at = coalesce(?, created_at)
|
|
56
57
|
where id = ?
|
|
@@ -66,6 +67,7 @@ export async function hydrateProfilesFromX() {
|
|
|
66
67
|
display_name = ?,
|
|
67
68
|
bio = ?,
|
|
68
69
|
followers_count = ?,
|
|
70
|
+
following_count = coalesce(?, following_count),
|
|
69
71
|
avatar_url = coalesce(?, avatar_url),
|
|
70
72
|
created_at = coalesce(?, created_at)
|
|
71
73
|
where id = 'profile_me'
|
|
@@ -97,6 +99,9 @@ export async function hydrateProfilesFromX() {
|
|
|
97
99
|
displayName || username || profileId,
|
|
98
100
|
String(user.description ?? ""),
|
|
99
101
|
toInt(metrics?.followers_count),
|
|
102
|
+
metrics && "following_count" in metrics
|
|
103
|
+
? toInt(metrics.following_count)
|
|
104
|
+
: null,
|
|
100
105
|
normalizeAvatarUrl(user.profile_image_url),
|
|
101
106
|
typeof user.created_at === "string" ? user.created_at : null,
|
|
102
107
|
profileId,
|
|
@@ -120,6 +125,9 @@ export async function hydrateProfilesFromX() {
|
|
|
120
125
|
String(me.name ?? "Peter Steinberger"),
|
|
121
126
|
String(me.description ?? ""),
|
|
122
127
|
toInt(metrics?.followers_count),
|
|
128
|
+
metrics && "following_count" in metrics
|
|
129
|
+
? toInt(metrics.following_count)
|
|
130
|
+
: null,
|
|
123
131
|
normalizeAvatarUrl(me.profile_image_url),
|
|
124
132
|
typeof me.created_at === "string" ? me.created_at : null,
|
|
125
133
|
);
|
|
@@ -16,7 +16,7 @@ export async function inspectProfileReplies(
|
|
|
16
16
|
): Promise<ProfileRepliesResponse> {
|
|
17
17
|
const resolved = await resolveProfile(query);
|
|
18
18
|
if (!resolved.externalUserId) {
|
|
19
|
-
throw new Error(`Profile has no external
|
|
19
|
+
throw new Error(`Profile has no external Twitter user id: ${query}`);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const timeline = await listUserTweets(resolved.externalUserId, {
|