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.
@@ -0,0 +1,175 @@
1
+ import type Database from "better-sqlite3";
2
+ import { listHomeTimelineViaBird } from "./bird";
3
+ import { getNativeDb } from "./db";
4
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+ import type { XurlMentionData, XurlMentionsResponse } from "./types";
6
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
7
+
8
+ const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
9
+
10
+ function parseCacheTtlMs(value?: number) {
11
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
12
+ return DEFAULT_TIMELINE_CACHE_TTL_MS;
13
+ }
14
+ return Math.floor(value);
15
+ }
16
+
17
+ function assertLimit(limit: number) {
18
+ if (!Number.isFinite(limit) || limit < 1) {
19
+ throw new Error("--limit must be at least 1");
20
+ }
21
+ }
22
+
23
+ function resolveAccount(db: Database.Database, accountId?: string) {
24
+ const row = accountId
25
+ ? (db.prepare("select id from accounts where id = ?").get(accountId) as
26
+ | { id: string }
27
+ | undefined)
28
+ : (db
29
+ .prepare(
30
+ `
31
+ select id
32
+ from accounts
33
+ order by is_default desc, created_at asc
34
+ limit 1
35
+ `,
36
+ )
37
+ .get() as { id: string } | undefined);
38
+
39
+ if (!row) {
40
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
41
+ }
42
+
43
+ return row.id;
44
+ }
45
+
46
+ function getMediaCount(tweet: XurlMentionData) {
47
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
48
+ return urls.filter(
49
+ (url) =>
50
+ url &&
51
+ typeof url === "object" &&
52
+ typeof (url as Record<string, unknown>).media_key === "string",
53
+ ).length;
54
+ }
55
+
56
+ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
57
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
58
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
59
+ tweetId,
60
+ text,
61
+ );
62
+ }
63
+
64
+ function mergeHomeTimelineIntoLocalStore(
65
+ db: Database.Database,
66
+ accountId: string,
67
+ payload: XurlMentionsResponse,
68
+ ) {
69
+ const usersById = new Map(
70
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
71
+ );
72
+ const upsertTweet = db.prepare(
73
+ `
74
+ insert into tweets (
75
+ id, account_id, author_profile_id, kind, text, created_at,
76
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
77
+ entities_json, media_json, quoted_tweet_id
78
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
79
+ on conflict(id) do update set
80
+ account_id = excluded.account_id,
81
+ author_profile_id = excluded.author_profile_id,
82
+ kind = case
83
+ when tweets.kind = 'mention' then tweets.kind
84
+ else 'home'
85
+ end,
86
+ text = excluded.text,
87
+ created_at = excluded.created_at,
88
+ like_count = excluded.like_count,
89
+ media_count = excluded.media_count,
90
+ entities_json = excluded.entities_json,
91
+ media_json = excluded.media_json,
92
+ bookmarked = tweets.bookmarked,
93
+ liked = tweets.liked
94
+ `,
95
+ );
96
+
97
+ db.transaction(() => {
98
+ for (const tweet of payload.data) {
99
+ const author =
100
+ usersById.get(tweet.author_id) ??
101
+ ({
102
+ id: tweet.author_id,
103
+ username: `user_${tweet.author_id}`,
104
+ name: `user_${tweet.author_id}`,
105
+ } as const);
106
+ const profile = usersById.has(tweet.author_id)
107
+ ? upsertProfileFromXUser(db, author)
108
+ : ensureStubProfileForXUser(db, tweet.author_id);
109
+ upsertTweet.run(
110
+ tweet.id,
111
+ accountId,
112
+ profile.profile.id,
113
+ tweet.text,
114
+ tweet.created_at,
115
+ Number(tweet.public_metrics?.like_count ?? 0),
116
+ getMediaCount(tweet),
117
+ JSON.stringify(tweet.entities ?? {}),
118
+ );
119
+ replaceTweetFts(db, tweet.id, tweet.text);
120
+ }
121
+ })();
122
+ }
123
+
124
+ export async function syncHomeTimeline({
125
+ account,
126
+ limit = 100,
127
+ following = true,
128
+ refresh = false,
129
+ cacheTtlMs,
130
+ }: {
131
+ account?: string;
132
+ limit?: number;
133
+ following?: boolean;
134
+ refresh?: boolean;
135
+ cacheTtlMs?: number;
136
+ }) {
137
+ assertLimit(limit);
138
+ const db = getNativeDb();
139
+ const accountId = resolveAccount(db, account);
140
+ const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
141
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
142
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
143
+ const cacheAgeMs = cached
144
+ ? Date.now() - new Date(cached.updatedAt).getTime()
145
+ : Number.POSITIVE_INFINITY;
146
+
147
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
148
+ return {
149
+ ok: true,
150
+ source: "cache",
151
+ kind: "timeline",
152
+ accountId,
153
+ feed: following ? "following" : "for-you",
154
+ count: cached.value.data.length,
155
+ payload: cached.value,
156
+ };
157
+ }
158
+
159
+ const payload = await listHomeTimelineViaBird({
160
+ maxResults: limit,
161
+ following,
162
+ });
163
+ mergeHomeTimelineIntoLocalStore(db, accountId, payload);
164
+ writeSyncCache(cacheKey, payload, db);
165
+
166
+ return {
167
+ ok: true,
168
+ source: "bird",
169
+ kind: "timeline",
170
+ accountId,
171
+ feed: following ? "following" : "for-you",
172
+ count: payload.data.length,
173
+ payload,
174
+ };
175
+ }
@@ -0,0 +1,35 @@
1
+ import { lookupTweetsByIdsViaBird } from "./bird";
2
+ import type { XurlTweetsResponse } from "./types";
3
+ import { lookupTweetsByIds as lookupTweetsByIdsViaXurl } from "./xurl";
4
+
5
+ export type TweetLookupMode = "auto" | "xurl" | "bird";
6
+
7
+ function errorMessage(error: unknown) {
8
+ return error instanceof Error ? error.message : String(error);
9
+ }
10
+
11
+ export async function lookupTweetsByIds(
12
+ ids: string[],
13
+ mode: TweetLookupMode = "auto",
14
+ ): Promise<XurlTweetsResponse> {
15
+ if (mode === "bird") {
16
+ return lookupTweetsByIdsViaBird(ids);
17
+ }
18
+ if (mode === "xurl") {
19
+ return lookupTweetsByIdsViaXurl(ids);
20
+ }
21
+
22
+ try {
23
+ return await lookupTweetsByIdsViaXurl(ids);
24
+ } catch (xurlError) {
25
+ try {
26
+ return await lookupTweetsByIdsViaBird(ids);
27
+ } catch (birdError) {
28
+ throw new Error(
29
+ `Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
30
+ xurlError,
31
+ )}; bird: ${errorMessage(birdError)}`,
32
+ );
33
+ }
34
+ }
35
+ }
package/src/lib/types.ts CHANGED
@@ -20,6 +20,7 @@ export interface ProfileRecord {
20
20
  displayName: string;
21
21
  bio: string;
22
22
  followersCount: number;
23
+ followingCount?: number;
23
24
  avatarHue: number;
24
25
  avatarUrl?: string;
25
26
  createdAt: string;
@@ -98,6 +99,7 @@ export interface TimelineItem {
98
99
  accountHandle: string;
99
100
  kind: "home" | "mention" | "like" | "bookmark";
100
101
  text: string;
102
+ searchSnippet?: string;
101
103
  createdAt: string;
102
104
  isReplied: boolean;
103
105
  likeCount: number;
@@ -109,6 +111,7 @@ export interface TimelineItem {
109
111
  media: TweetMediaItem[];
110
112
  replyToTweet?: EmbeddedTweet | null;
111
113
  quotedTweet?: EmbeddedTweet | null;
114
+ qualityReason?: string | null;
112
115
  }
113
116
 
114
117
  export interface DmMessageItem {
@@ -127,6 +130,7 @@ export interface DmConversationItem {
127
130
  accountId: string;
128
131
  accountHandle: string;
129
132
  title: string;
133
+ searchSnippet?: string;
130
134
  lastMessageAt: string;
131
135
  lastMessagePreview: string;
132
136
  unreadCount: number;
@@ -145,6 +149,8 @@ export interface TimelineQuery {
145
149
  until?: string;
146
150
  includeReplies?: boolean;
147
151
  qualityFilter?: TimelineQualityFilter;
152
+ lowQualityThreshold?: number;
153
+ includeQualityReason?: boolean;
148
154
  likedOnly?: boolean;
149
155
  bookmarkedOnly?: boolean;
150
156
  limit?: number;
@@ -171,7 +177,7 @@ export interface TransportStatus {
171
177
  }
172
178
 
173
179
  export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
174
- export type ModerationTransportKind = "bird" | "xurl";
180
+ export type ModerationTransportKind = "bird" | "xurl" | "x-web";
175
181
 
176
182
  export interface ModerationActionTransportResult {
177
183
  ok: boolean;
@@ -252,6 +258,7 @@ export interface XurlPublicMetrics {
252
258
  bookmark_count?: number;
253
259
  impression_count?: number;
254
260
  followers_count?: number;
261
+ following_count?: number;
255
262
  }
256
263
 
257
264
  export interface XurlMentionUser {
@@ -271,6 +278,7 @@ export interface XurlMentionData {
271
278
  created_at: string;
272
279
  conversation_id?: string;
273
280
  entities?: Record<string, unknown>;
281
+ referenced_tweets?: XurlReferencedTweet[];
274
282
  public_metrics?: XurlPublicMetrics;
275
283
  edit_history_tweet_ids?: string[];
276
284
  }
@@ -290,6 +298,18 @@ export interface XurlUserTweet {
290
298
  edit_history_tweet_ids?: string[];
291
299
  }
292
300
 
301
+ export interface XurlTweetData {
302
+ id: string;
303
+ author_id: string;
304
+ text: string;
305
+ created_at: string;
306
+ conversation_id?: string;
307
+ entities?: Record<string, unknown>;
308
+ referenced_tweets?: XurlReferencedTweet[];
309
+ public_metrics?: XurlPublicMetrics;
310
+ edit_history_tweet_ids?: string[];
311
+ }
312
+
293
313
  export interface ProfileReplyItem {
294
314
  id: string;
295
315
  text: string;
@@ -322,3 +342,11 @@ export interface XurlMentionsResponse {
322
342
  };
323
343
  meta?: Record<string, unknown>;
324
344
  }
345
+
346
+ export interface XurlTweetsResponse {
347
+ data: XurlTweetData[];
348
+ includes?: {
349
+ users?: XurlMentionUser[];
350
+ };
351
+ meta?: Record<string, unknown>;
352
+ }
@@ -27,12 +27,14 @@ export function randomAvatarHue(input: string) {
27
27
  }
28
28
 
29
29
  function toProfile(row: Record<string, unknown>): ProfileRecord {
30
+ const followingCount = Number(row.following_count ?? 0);
30
31
  return {
31
32
  id: String(row.id),
32
33
  handle: String(row.handle),
33
34
  displayName: String(row.display_name),
34
35
  bio: String(row.bio),
35
36
  followersCount: Number(row.followers_count),
37
+ ...(Number.isFinite(followingCount) ? { followingCount } : {}),
36
38
  avatarHue: Number(row.avatar_hue),
37
39
  avatarUrl:
38
40
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
@@ -48,6 +50,11 @@ function updateExistingProfileFromUser(
48
50
  const username = String(user.username ?? "").replace(/^@/, "");
49
51
  const displayName = String(user.name ?? "").trim() || username;
50
52
  const followersCount = Number(user.public_metrics?.followers_count ?? 0);
53
+ const hasFollowingCount =
54
+ typeof user.public_metrics?.following_count === "number";
55
+ const followingCount = hasFollowingCount
56
+ ? (user.public_metrics?.following_count ?? null)
57
+ : null;
51
58
  const bio = String(user.description ?? "");
52
59
  const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
53
60
 
@@ -58,15 +65,24 @@ function updateExistingProfileFromUser(
58
65
  display_name = ?,
59
66
  bio = ?,
60
67
  followers_count = ?,
68
+ following_count = coalesce(?, following_count),
61
69
  avatar_url = coalesce(?, avatar_url)
62
70
  where id = ?
63
71
  `,
64
- ).run(username, displayName, bio, followersCount, avatarUrl, profileId);
72
+ ).run(
73
+ username,
74
+ displayName,
75
+ bio,
76
+ followersCount,
77
+ followingCount,
78
+ avatarUrl,
79
+ profileId,
80
+ );
65
81
 
66
82
  const row = db
67
83
  .prepare(
68
84
  `
69
- select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
85
+ select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
70
86
  from profiles
71
87
  where id = ?
72
88
  `,
@@ -111,6 +127,11 @@ export function upsertProfileFromXUser(
111
127
 
112
128
  const displayName = String(user.name ?? "").trim() || username;
113
129
  const followersCount = Number(user.public_metrics?.followers_count ?? 0);
130
+ const hasFollowingCount =
131
+ typeof user.public_metrics?.following_count === "number";
132
+ const followingCount = hasFollowingCount
133
+ ? (user.public_metrics?.following_count ?? null)
134
+ : null;
114
135
  const bio = String(user.description ?? "");
115
136
  const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
116
137
  const createdAt = new Date().toISOString();
@@ -119,14 +140,18 @@ export function upsertProfileFromXUser(
119
140
  db.prepare(
120
141
  `
121
142
  insert into profiles (
122
- id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
123
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
143
+ id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
144
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
124
145
  on conflict(id) do update set
125
- handle = excluded.handle,
126
- display_name = excluded.display_name,
127
- bio = excluded.bio,
128
- followers_count = excluded.followers_count,
129
- avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url)
146
+ handle = excluded.handle,
147
+ display_name = excluded.display_name,
148
+ bio = excluded.bio,
149
+ followers_count = excluded.followers_count,
150
+ following_count = case
151
+ when ? then excluded.following_count
152
+ else profiles.following_count
153
+ end,
154
+ avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url)
130
155
  `,
131
156
  ).run(
132
157
  profileId,
@@ -134,9 +159,11 @@ export function upsertProfileFromXUser(
134
159
  displayName,
135
160
  bio,
136
161
  followersCount,
162
+ followingCount ?? 0,
137
163
  avatarHue,
138
164
  avatarUrl,
139
165
  createdAt,
166
+ hasFollowingCount ? 1 : 0,
140
167
  );
141
168
 
142
169
  return {
@@ -146,6 +173,7 @@ export function upsertProfileFromXUser(
146
173
  displayName,
147
174
  bio,
148
175
  followersCount,
176
+ followingCount: followingCount ?? 0,
149
177
  avatarHue,
150
178
  avatarUrl: avatarUrl ?? undefined,
151
179
  createdAt,
@@ -162,7 +190,7 @@ export function ensureStubProfileForXUser(
162
190
  const existingRow = db
163
191
  .prepare(
164
192
  `
165
- select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
193
+ select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
166
194
  from profiles
167
195
  where id = ?
168
196
  limit 1
@@ -183,8 +211,8 @@ export function ensureStubProfileForXUser(
183
211
  db.prepare(
184
212
  `
185
213
  insert into profiles (
186
- id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
187
- ) values (?, ?, ?, '', 0, ?, null, ?)
214
+ id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at
215
+ ) values (?, ?, ?, '', 0, 0, ?, null, ?)
188
216
  `,
189
217
  ).run(profileId, handle, handle, avatarHue, createdAt);
190
218
 
@@ -195,6 +223,7 @@ export function ensureStubProfileForXUser(
195
223
  displayName: handle,
196
224
  bio: "",
197
225
  followersCount: 0,
226
+ followingCount: 0,
198
227
  avatarHue,
199
228
  createdAt,
200
229
  },
package/src/lib/xurl.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  TransportStatus,
5
5
  XurlMentionsResponse,
6
6
  XurlMentionUser,
7
+ XurlTweetsResponse,
7
8
  XurlUserTweet,
8
9
  } from "./types";
9
10
 
@@ -317,13 +318,13 @@ export async function listMentionsViaXurl({
317
318
  if (username) {
318
319
  const [user] = await lookupUsersByHandles([username]);
319
320
  if (!user?.id) {
320
- throw new Error(`Could not resolve X user id for @${username}`);
321
+ throw new Error(`Could not resolve Twitter user id for @${username}`);
321
322
  }
322
323
  resolvedUserId = String(user.id);
323
324
  } else {
324
325
  const user = await lookupAuthenticatedUser();
325
326
  if (!user?.id) {
326
- throw new Error("Could not resolve authenticated X user id");
327
+ throw new Error("Could not resolve authenticated Twitter user id");
327
328
  }
328
329
  resolvedUserId = String(user.id);
329
330
  }
@@ -376,13 +377,13 @@ async function listTimelineCollectionViaXurl({
376
377
  if (username) {
377
378
  const [user] = await lookupUsersByHandles([username]);
378
379
  if (!user?.id) {
379
- throw new Error(`Could not resolve X user id for @${username}`);
380
+ throw new Error(`Could not resolve Twitter user id for @${username}`);
380
381
  }
381
382
  resolvedUserId = String(user.id);
382
383
  } else {
383
384
  const user = await lookupAuthenticatedUser();
384
385
  if (!user?.id) {
385
- throw new Error("Could not resolve authenticated X user id");
386
+ throw new Error("Could not resolve authenticated Twitter user id");
386
387
  }
387
388
  resolvedUserId = String(user.id);
388
389
  }
@@ -391,7 +392,8 @@ async function listTimelineCollectionViaXurl({
391
392
  const query = new URLSearchParams({
392
393
  max_results: String(maxResults),
393
394
  expansions: "author_id",
394
- "tweet.fields": "created_at,conversation_id,entities,public_metrics",
395
+ "tweet.fields":
396
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
395
397
  "user.fields":
396
398
  "description,public_metrics,profile_image_url,created_at,verified",
397
399
  });
@@ -513,6 +515,38 @@ export async function listUserTweets(
513
515
  };
514
516
  }
515
517
 
518
+ export async function lookupTweetsByIds(
519
+ ids: string[],
520
+ ): Promise<XurlTweetsResponse> {
521
+ if (ids.length === 0) {
522
+ return { data: [] };
523
+ }
524
+
525
+ const query = new URLSearchParams({
526
+ ids: ids.join(","),
527
+ expansions: "author_id",
528
+ "tweet.fields":
529
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
530
+ "user.fields":
531
+ "description,public_metrics,profile_image_url,created_at,verified",
532
+ });
533
+
534
+ const payload = await runJsonCommand([`/2/tweets?${query.toString()}`]);
535
+ return {
536
+ data: Array.isArray(payload.data)
537
+ ? (payload.data as XurlTweetsResponse["data"])
538
+ : [],
539
+ includes:
540
+ payload.includes && typeof payload.includes === "object"
541
+ ? (payload.includes as XurlTweetsResponse["includes"])
542
+ : undefined,
543
+ meta:
544
+ payload.meta && typeof payload.meta === "object"
545
+ ? (payload.meta as XurlTweetsResponse["meta"])
546
+ : undefined,
547
+ };
548
+ }
549
+
516
550
  export async function postViaXurl(text: string) {
517
551
  return runShortcut(["post", text]);
518
552
  }
@@ -240,7 +240,7 @@ function BlocksRoute() {
240
240
  className={cx(textFieldClass, textFieldWideClass)}
241
241
  disabled={!hasAccountId}
242
242
  onChange={(event) => setSearch(event.target.value)}
243
- placeholder="Handle, name, bio, or X URL"
243
+ placeholder="Handle, name, bio, or Twitter URL"
244
244
  value={search}
245
245
  />
246
246
  <button