birdclaw 0.2.1 → 0.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +54 -15
  3. package/package.json +14 -15
  4. package/playwright.config.ts +5 -2
  5. package/src/cli.ts +289 -20
  6. package/src/lib/actions-transport.ts +58 -1
  7. package/src/lib/archive-import.ts +34 -2
  8. package/src/lib/backup.ts +271 -33
  9. package/src/lib/bird-actions.ts +21 -0
  10. package/src/lib/bird.ts +332 -17
  11. package/src/lib/blocks.ts +3 -3
  12. package/src/lib/bookmark-sync-job.ts +3 -3
  13. package/src/lib/config.ts +34 -1
  14. package/src/lib/db.ts +321 -14
  15. package/src/lib/dms-live.ts +3 -3
  16. package/src/lib/identity-search-index.ts +369 -0
  17. package/src/lib/mention-threads-live.ts +267 -0
  18. package/src/lib/mentions-live.ts +18 -7
  19. package/src/lib/moderation-target.ts +7 -5
  20. package/src/lib/moderation-write.ts +3 -3
  21. package/src/lib/profile-affiliation-hydration.ts +189 -0
  22. package/src/lib/profile-affiliations.ts +262 -0
  23. package/src/lib/profile-bio-entities.ts +318 -0
  24. package/src/lib/profile-history.ts +164 -0
  25. package/src/lib/profile-hydration.ts +8 -24
  26. package/src/lib/profile-resolver.ts +428 -0
  27. package/src/lib/queries.ts +522 -68
  28. package/src/lib/research.ts +607 -0
  29. package/src/lib/seed.ts +10 -4
  30. package/src/lib/sqlite.ts +143 -0
  31. package/src/lib/timeline-collections-live.ts +22 -8
  32. package/src/lib/timeline-live.ts +182 -0
  33. package/src/lib/tweet-account-edges.ts +39 -0
  34. package/src/lib/tweet-lookup.ts +35 -0
  35. package/src/lib/types.ts +103 -1
  36. package/src/lib/url-expansion.ts +147 -0
  37. package/src/lib/whois.ts +1060 -0
  38. package/src/lib/x-profile.ts +174 -17
  39. package/src/lib/xurl.ts +41 -6
@@ -1,5 +1,9 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
  import { normalizeAvatarUrl } from "./avatar-cache";
3
+ import { syncIdentitySearchIndexForProfileIds } from "./identity-search-index";
4
+ import { syncProfileBioEntitiesForProfileId } from "./profile-bio-entities";
5
+ import { syncProfileAffiliationsFromUser } from "./profile-affiliations";
6
+ import { recordProfileSnapshot } from "./profile-history";
3
7
  import type { ProfileRecord, XurlMentionUser } from "./types";
4
8
 
5
9
  export interface ResolvedXProfile {
@@ -27,30 +31,117 @@ export function randomAvatarHue(input: string) {
27
31
  }
28
32
 
29
33
  function toProfile(row: Record<string, unknown>): ProfileRecord {
34
+ const followingCount = Number(row.following_count ?? 0);
35
+ const entities = parseJsonRecord(row.entities_json);
30
36
  return {
31
37
  id: String(row.id),
32
38
  handle: String(row.handle),
33
39
  displayName: String(row.display_name),
34
40
  bio: String(row.bio),
35
41
  followersCount: Number(row.followers_count),
42
+ ...(Number.isFinite(followingCount) ? { followingCount } : {}),
36
43
  avatarHue: Number(row.avatar_hue),
37
44
  avatarUrl:
38
45
  typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
46
+ ...(typeof row.location === "string" && row.location.length > 0
47
+ ? { location: row.location }
48
+ : {}),
49
+ ...(typeof row.url === "string" && row.url.length > 0
50
+ ? { url: row.url }
51
+ : {}),
52
+ ...(typeof row.verified_type === "string" && row.verified_type.length > 0
53
+ ? { verifiedType: row.verified_type }
54
+ : {}),
55
+ ...(entities ? { entities } : {}),
39
56
  createdAt: String(row.created_at),
40
57
  };
41
58
  }
42
59
 
60
+ function parseJsonRecord(value: unknown) {
61
+ if (typeof value !== "string" || value.length === 0) {
62
+ return undefined;
63
+ }
64
+ try {
65
+ const parsed = JSON.parse(value) as unknown;
66
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
67
+ ? (parsed as Record<string, unknown>)
68
+ : undefined;
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ function getString(value: unknown) {
75
+ return typeof value === "string" && value.trim().length > 0
76
+ ? value.trim()
77
+ : undefined;
78
+ }
79
+
80
+ function getExpandedUrlFromEntities(
81
+ entities: Record<string, unknown> | undefined,
82
+ key: "url" | "description",
83
+ ) {
84
+ const block = entities?.[key];
85
+ if (!block || typeof block !== "object") {
86
+ return undefined;
87
+ }
88
+ const urls = (block as { urls?: unknown }).urls;
89
+ if (!Array.isArray(urls)) {
90
+ return undefined;
91
+ }
92
+ for (const entry of urls) {
93
+ if (!entry || typeof entry !== "object") {
94
+ continue;
95
+ }
96
+ const record = entry as Record<string, unknown>;
97
+ const expanded =
98
+ getString(record.expandedUrl) ?? getString(record.expanded_url);
99
+ if (expanded) {
100
+ return expanded;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ function normalizeVerifiedType(user: XurlMentionUser) {
107
+ const verifiedType = getString(user.verified_type);
108
+ if (verifiedType) {
109
+ return verifiedType.toLowerCase();
110
+ }
111
+ return user.verified ? "verified" : null;
112
+ }
113
+
114
+ function buildProfileMetadata(user: XurlMentionUser) {
115
+ const entities = user.entities;
116
+ const url =
117
+ getExpandedUrlFromEntities(entities, "url") ?? getString(user.url);
118
+ return {
119
+ location: getString(user.location) ?? null,
120
+ url: url ?? null,
121
+ verifiedType: normalizeVerifiedType(user),
122
+ entitiesJson: JSON.stringify(entities ?? {}),
123
+ rawJson: JSON.stringify(user),
124
+ };
125
+ }
126
+
43
127
  function updateExistingProfileFromUser(
44
- db: Database.Database,
128
+ db: Database,
45
129
  profileId: string,
46
130
  user: XurlMentionUser,
47
131
  ): ResolvedXProfile {
48
132
  const username = String(user.username ?? "").replace(/^@/, "");
49
133
  const displayName = String(user.name ?? "").trim() || username;
50
134
  const followersCount = Number(user.public_metrics?.followers_count ?? 0);
135
+ const hasFollowingCount =
136
+ typeof user.public_metrics?.following_count === "number";
137
+ const followingCount = hasFollowingCount
138
+ ? (user.public_metrics?.following_count ?? null)
139
+ : null;
51
140
  const bio = String(user.description ?? "");
52
141
  const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
142
+ const metadata = buildProfileMetadata(user);
53
143
 
144
+ recordProfileSnapshot(db, profileId, "pre_update");
54
145
  db.prepare(
55
146
  `
56
147
  update profiles
@@ -58,15 +149,43 @@ function updateExistingProfileFromUser(
58
149
  display_name = ?,
59
150
  bio = ?,
60
151
  followers_count = ?,
61
- avatar_url = coalesce(?, avatar_url)
152
+ following_count = coalesce(?, following_count),
153
+ avatar_url = coalesce(?, avatar_url),
154
+ location = coalesce(?, location),
155
+ url = coalesce(?, url),
156
+ verified_type = coalesce(?, verified_type),
157
+ entities_json = case
158
+ when ? not in ('', '{}', 'null') then ?
159
+ else profiles.entities_json
160
+ end,
161
+ raw_json = ?
62
162
  where id = ?
63
163
  `,
64
- ).run(username, displayName, bio, followersCount, avatarUrl, profileId);
164
+ ).run(
165
+ username,
166
+ displayName,
167
+ bio,
168
+ followersCount,
169
+ followingCount,
170
+ avatarUrl,
171
+ metadata.location,
172
+ metadata.url,
173
+ metadata.verifiedType,
174
+ metadata.entitiesJson,
175
+ metadata.entitiesJson,
176
+ metadata.rawJson,
177
+ profileId,
178
+ );
179
+ syncProfileAffiliationsFromUser(db, profileId, user);
180
+ recordProfileSnapshot(db, profileId, "x_profile");
181
+ syncProfileBioEntitiesForProfileId(db, profileId);
182
+ syncIdentitySearchIndexForProfileIds(db, [profileId]);
65
183
 
66
184
  const row = db
67
185
  .prepare(
68
186
  `
69
- select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
187
+ select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url,
188
+ location, url, verified_type, entities_json, created_at
70
189
  from profiles
71
190
  where id = ?
72
191
  `,
@@ -80,7 +199,7 @@ function updateExistingProfileFromUser(
80
199
  }
81
200
 
82
201
  export function upsertProfileFromXUser(
83
- db: Database.Database,
202
+ db: Database,
84
203
  user: XurlMentionUser,
85
204
  ): ResolvedXProfile {
86
205
  const externalUserId = String(user.id ?? "");
@@ -111,22 +230,41 @@ export function upsertProfileFromXUser(
111
230
 
112
231
  const displayName = String(user.name ?? "").trim() || username;
113
232
  const followersCount = Number(user.public_metrics?.followers_count ?? 0);
233
+ const hasFollowingCount =
234
+ typeof user.public_metrics?.following_count === "number";
235
+ const followingCount = hasFollowingCount
236
+ ? (user.public_metrics?.following_count ?? null)
237
+ : null;
114
238
  const bio = String(user.description ?? "");
115
239
  const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
240
+ const metadata = buildProfileMetadata(user);
116
241
  const createdAt = new Date().toISOString();
117
242
  const avatarHue = randomAvatarHue(username);
118
243
 
119
244
  db.prepare(
120
245
  `
121
246
  insert into profiles (
122
- id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
123
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
247
+ id, handle, display_name, bio, followers_count, following_count, avatar_hue,
248
+ avatar_url, location, url, verified_type, entities_json, raw_json, created_at
249
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
124
250
  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)
251
+ handle = excluded.handle,
252
+ display_name = excluded.display_name,
253
+ bio = excluded.bio,
254
+ followers_count = excluded.followers_count,
255
+ following_count = case
256
+ when ? then excluded.following_count
257
+ else profiles.following_count
258
+ end,
259
+ avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
260
+ location = coalesce(excluded.location, profiles.location),
261
+ url = coalesce(excluded.url, profiles.url),
262
+ verified_type = coalesce(excluded.verified_type, profiles.verified_type),
263
+ entities_json = case
264
+ when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
265
+ else profiles.entities_json
266
+ end,
267
+ raw_json = excluded.raw_json
130
268
  `,
131
269
  ).run(
132
270
  profileId,
@@ -134,10 +272,21 @@ export function upsertProfileFromXUser(
134
272
  displayName,
135
273
  bio,
136
274
  followersCount,
275
+ followingCount ?? 0,
137
276
  avatarHue,
138
277
  avatarUrl,
278
+ metadata.location,
279
+ metadata.url,
280
+ metadata.verifiedType,
281
+ metadata.entitiesJson,
282
+ metadata.rawJson,
139
283
  createdAt,
284
+ hasFollowingCount ? 1 : 0,
140
285
  );
286
+ syncProfileAffiliationsFromUser(db, profileId, user);
287
+ recordProfileSnapshot(db, profileId, "x_profile");
288
+ syncProfileBioEntitiesForProfileId(db, profileId);
289
+ syncIdentitySearchIndexForProfileIds(db, [profileId]);
141
290
 
142
291
  return {
143
292
  profile: {
@@ -146,8 +295,13 @@ export function upsertProfileFromXUser(
146
295
  displayName,
147
296
  bio,
148
297
  followersCount,
298
+ followingCount: followingCount ?? 0,
149
299
  avatarHue,
150
300
  avatarUrl: avatarUrl ?? undefined,
301
+ ...(metadata.location ? { location: metadata.location } : {}),
302
+ ...(metadata.url ? { url: metadata.url } : {}),
303
+ ...(metadata.verifiedType ? { verifiedType: metadata.verifiedType } : {}),
304
+ ...(user.entities ? { entities: user.entities } : {}),
151
305
  createdAt,
152
306
  },
153
307
  externalUserId,
@@ -155,14 +309,15 @@ export function upsertProfileFromXUser(
155
309
  }
156
310
 
157
311
  export function ensureStubProfileForXUser(
158
- db: Database.Database,
312
+ db: Database,
159
313
  externalUserId: string,
160
314
  ): ResolvedXProfile {
161
315
  const profileId = buildExternalProfileId(externalUserId);
162
316
  const existingRow = db
163
317
  .prepare(
164
318
  `
165
- select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
319
+ select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url,
320
+ location, url, verified_type, entities_json, created_at
166
321
  from profiles
167
322
  where id = ?
168
323
  limit 1
@@ -183,8 +338,9 @@ export function ensureStubProfileForXUser(
183
338
  db.prepare(
184
339
  `
185
340
  insert into profiles (
186
- id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at
187
- ) values (?, ?, ?, '', 0, ?, null, ?)
341
+ id, handle, display_name, bio, followers_count, following_count, avatar_hue,
342
+ avatar_url, location, url, verified_type, entities_json, raw_json, created_at
343
+ ) values (?, ?, ?, '', 0, 0, ?, null, null, null, null, '{}', '{}', ?)
188
344
  `,
189
345
  ).run(profileId, handle, handle, avatarHue, createdAt);
190
346
 
@@ -195,6 +351,7 @@ export function ensureStubProfileForXUser(
195
351
  displayName: handle,
196
352
  bio: "",
197
353
  followersCount: 0,
354
+ followingCount: 0,
198
355
  avatarHue,
199
356
  createdAt,
200
357
  },
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
 
@@ -239,7 +240,7 @@ export async function lookupUsersByIds(ids: string[]) {
239
240
  const query = new URLSearchParams({
240
241
  ids: ids.join(","),
241
242
  "user.fields":
242
- "description,public_metrics,profile_image_url,created_at,verified",
243
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
243
244
  });
244
245
  const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
245
246
  const data = payload.data;
@@ -254,7 +255,7 @@ export async function lookupUsersByHandles(handles: string[]) {
254
255
  const query = new URLSearchParams({
255
256
  usernames: handles.map((item) => item.replace(/^@/, "")).join(","),
256
257
  "user.fields":
257
- "description,public_metrics,profile_image_url,created_at,verified",
258
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
258
259
  });
259
260
  const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
260
261
  const data = payload.data;
@@ -334,7 +335,7 @@ export async function listMentionsViaXurl({
334
335
  expansions: "author_id",
335
336
  "tweet.fields": "created_at,conversation_id,entities,public_metrics",
336
337
  "user.fields":
337
- "description,public_metrics,profile_image_url,created_at,verified",
338
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
338
339
  });
339
340
  if (paginationToken) {
340
341
  query.set("pagination_token", paginationToken);
@@ -391,9 +392,10 @@ 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
- "description,public_metrics,profile_image_url,created_at,verified",
398
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
397
399
  });
398
400
  if (paginationToken) {
399
401
  query.set("pagination_token", paginationToken);
@@ -449,7 +451,8 @@ export async function listBlockedUsers(
449
451
  ) {
450
452
  const query = new URLSearchParams({
451
453
  max_results: "100",
452
- "user.fields": "description,public_metrics,profile_image_url,created_at",
454
+ "user.fields":
455
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
453
456
  });
454
457
  if (paginationToken) {
455
458
  query.set("pagination_token", paginationToken);
@@ -513,6 +516,38 @@ export async function listUserTweets(
513
516
  };
514
517
  }
515
518
 
519
+ export async function lookupTweetsByIds(
520
+ ids: string[],
521
+ ): Promise<XurlTweetsResponse> {
522
+ if (ids.length === 0) {
523
+ return { data: [] };
524
+ }
525
+
526
+ const query = new URLSearchParams({
527
+ ids: ids.join(","),
528
+ expansions: "author_id",
529
+ "tweet.fields":
530
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
531
+ "user.fields":
532
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
533
+ });
534
+
535
+ const payload = await runJsonCommand([`/2/tweets?${query.toString()}`]);
536
+ return {
537
+ data: Array.isArray(payload.data)
538
+ ? (payload.data as XurlTweetsResponse["data"])
539
+ : [],
540
+ includes:
541
+ payload.includes && typeof payload.includes === "object"
542
+ ? (payload.includes as XurlTweetsResponse["includes"])
543
+ : undefined,
544
+ meta:
545
+ payload.meta && typeof payload.meta === "object"
546
+ ? (payload.meta as XurlTweetsResponse["meta"])
547
+ : undefined,
548
+ };
549
+ }
550
+
516
551
  export async function postViaXurl(text: string) {
517
552
  return runShortcut(["post", text]);
518
553
  }