birdclaw 0.2.1 → 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/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 birdCommand = getBirdCommand();
228
- const { stdout } = await execFileAsync(
229
- birdCommand,
230
- ["mentions", "-n", String(maxResults), "--json"],
231
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
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 { stdout } = await execFileAsync(birdCommand, args, {
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 birdCommand = getBirdCommand();
305
- const { stdout } = await execFileAsync(
306
- birdCommand,
307
- ["dms", "-n", String(maxResults), "--json"],
308
- { maxBuffer: BIRD_JSON_MAX_BUFFER_BYTES },
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 { existsSync, mkdirSync, readFileSync } from "node:fs";
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
+ }
@@ -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
@@ -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
  );