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
@@ -0,0 +1,143 @@
1
+ import { DatabaseSync, type StatementSync } from "node:sqlite";
2
+
3
+ export type Database = NativeSqliteDatabase;
4
+
5
+ type DatabaseOptions = {
6
+ readonly?: boolean;
7
+ fileMustExist?: boolean;
8
+ };
9
+
10
+ type PragmaOptions = {
11
+ simple?: boolean;
12
+ };
13
+
14
+ type RunResult = {
15
+ changes: number;
16
+ lastInsertRowid: number;
17
+ };
18
+
19
+ function bindArgs(parameters: unknown[]) {
20
+ if (parameters.length === 1 && Array.isArray(parameters[0])) {
21
+ return parameters[0];
22
+ }
23
+ return parameters;
24
+ }
25
+
26
+ function normalizeValue(value: unknown): unknown {
27
+ if (value instanceof Uint8Array && !Buffer.isBuffer(value)) {
28
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ function normalizeRow(row: unknown): unknown {
34
+ if (
35
+ !row ||
36
+ typeof row !== "object" ||
37
+ Array.isArray(row) ||
38
+ Buffer.isBuffer(row)
39
+ ) {
40
+ return normalizeValue(row);
41
+ }
42
+ return Object.fromEntries(
43
+ Object.entries(row as Record<string, unknown>).map(([key, value]) => [
44
+ key,
45
+ normalizeValue(value),
46
+ ]),
47
+ );
48
+ }
49
+
50
+ class NativeSqliteStatement {
51
+ readonly reader: boolean;
52
+
53
+ constructor(private readonly statement: StatementSync) {
54
+ this.reader = statement.columns().length > 0;
55
+ }
56
+
57
+ all(...parameters: unknown[]): unknown[] {
58
+ return this.statement.all(...bindArgs(parameters)).map(normalizeRow);
59
+ }
60
+
61
+ get(...parameters: unknown[]): unknown {
62
+ return normalizeRow(this.statement.get(...bindArgs(parameters)));
63
+ }
64
+
65
+ run(...parameters: unknown[]): RunResult {
66
+ const result = this.statement.run(...bindArgs(parameters));
67
+ return {
68
+ changes: Number(result.changes),
69
+ lastInsertRowid: Number(result.lastInsertRowid),
70
+ };
71
+ }
72
+
73
+ iterate(...parameters: unknown[]): IterableIterator<unknown> {
74
+ const rows = this.statement.iterate(...bindArgs(parameters));
75
+ return (function* () {
76
+ for (const row of rows) {
77
+ yield normalizeRow(row);
78
+ }
79
+ })();
80
+ }
81
+ }
82
+
83
+ export class NativeSqliteDatabase {
84
+ private transactionDepth = 0;
85
+ private readonly db: DatabaseSync;
86
+
87
+ constructor(path: string, options: DatabaseOptions = {}) {
88
+ this.db = new DatabaseSync(path, {
89
+ readOnly: options.readonly,
90
+ });
91
+ }
92
+
93
+ close(): void {
94
+ if (!this.db.isOpen) {
95
+ return;
96
+ }
97
+ this.db.close();
98
+ }
99
+
100
+ exec(sql: string): void {
101
+ this.db.exec(sql);
102
+ }
103
+
104
+ pragma(sql: string, options: PragmaOptions = {}): unknown {
105
+ const rows = this.prepare(`pragma ${sql}`).all() as Array<
106
+ Record<string, unknown>
107
+ >;
108
+ if (!options.simple) {
109
+ return rows;
110
+ }
111
+ const first = rows[0];
112
+ return first ? Object.values(first)[0] : undefined;
113
+ }
114
+
115
+ prepare(sql: string): NativeSqliteStatement {
116
+ return new NativeSqliteStatement(this.db.prepare(sql));
117
+ }
118
+
119
+ transaction<TArgs extends unknown[], TResult>(
120
+ fn: (...args: TArgs) => TResult,
121
+ ): (...args: TArgs) => TResult {
122
+ return (...args: TArgs) => {
123
+ const nested = this.db.isTransaction;
124
+ const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
125
+ this.exec(nested ? `savepoint ${savepoint}` : "begin");
126
+ try {
127
+ const result = fn(...args);
128
+ this.exec(nested ? `release ${savepoint}` : "commit");
129
+ return result;
130
+ } catch (error) {
131
+ if (nested) {
132
+ this.exec(`rollback to ${savepoint}`);
133
+ this.exec(`release ${savepoint}`);
134
+ } else {
135
+ this.exec("rollback");
136
+ }
137
+ throw error;
138
+ }
139
+ };
140
+ }
141
+ }
142
+
143
+ export default NativeSqliteDatabase;
@@ -1,4 +1,4 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
  import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
4
  import { readSyncCache, writeSyncCache } from "./sync-cache";
@@ -50,7 +50,7 @@ function assertXurlLimit(limit: number) {
50
50
  }
51
51
  }
52
52
 
53
- function resolveAccount(db: Database.Database, accountId?: string) {
53
+ function resolveAccount(db: Database, accountId?: string) {
54
54
  const row = accountId
55
55
  ? (db
56
56
  .prepare(
@@ -97,7 +97,7 @@ function getMediaCount(tweet: XurlMentionData) {
97
97
  ).length;
98
98
  }
99
99
 
100
- function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
100
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
101
101
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
102
102
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
103
103
  tweetId,
@@ -105,6 +105,12 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
105
105
  );
106
106
  }
107
107
 
108
+ function getReferencedTweetId(tweet: XurlMentionData, type: string) {
109
+ return (
110
+ tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
111
+ );
112
+ }
113
+
108
114
  function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
109
115
  const tweets: XurlMentionData[] = [];
110
116
  const seenTweetIds = new Set<string>();
@@ -144,7 +150,7 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
144
150
  }
145
151
 
146
152
  function mergeTimelineCollectionIntoLocalStore(
147
- db: Database.Database,
153
+ db: Database,
148
154
  accountId: string,
149
155
  kind: TimelineCollectionKind,
150
156
  payload: XurlMentionsResponse,
@@ -162,9 +168,9 @@ function mergeTimelineCollectionIntoLocalStore(
162
168
  id, account_id, author_profile_id, kind, text, created_at,
163
169
  is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
164
170
  entities_json, media_json, quoted_tweet_id
165
- ) values (?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?, ?, '[]', null)
171
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
166
172
  on conflict(id) do update set
167
- account_id = excluded.account_id,
173
+ account_id = tweets.account_id,
168
174
  author_profile_id = excluded.author_profile_id,
169
175
  kind = case
170
176
  when tweets.kind in ('home', 'mention') then tweets.kind
@@ -176,8 +182,11 @@ function mergeTimelineCollectionIntoLocalStore(
176
182
  media_count = excluded.media_count,
177
183
  entities_json = excluded.entities_json,
178
184
  media_json = excluded.media_json,
179
- bookmarked = max(tweets.bookmarked, excluded.bookmarked),
180
- liked = max(tweets.liked, excluded.liked)
185
+ is_replied = max(tweets.is_replied, excluded.is_replied),
186
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
187
+ quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
188
+ bookmarked = tweets.bookmarked,
189
+ liked = tweets.liked
181
190
  `,
182
191
  );
183
192
  const upsertCollection = db.prepare(`
@@ -203,6 +212,8 @@ function mergeTimelineCollectionIntoLocalStore(
203
212
  const profile = usersById.has(tweet.author_id)
204
213
  ? upsertProfileFromXUser(db, author)
205
214
  : ensureStubProfileForXUser(db, tweet.author_id);
215
+ const replyToId = getReferencedTweetId(tweet, "replied_to");
216
+ const quotedTweetId = getReferencedTweetId(tweet, "quoted");
206
217
  upsertTweet.run(
207
218
  tweet.id,
208
219
  accountId,
@@ -210,11 +221,14 @@ function mergeTimelineCollectionIntoLocalStore(
210
221
  tweetKind,
211
222
  tweet.text,
212
223
  tweet.created_at,
224
+ replyToId ? 1 : 0,
225
+ replyToId,
213
226
  Number(tweet.public_metrics?.like_count ?? 0),
214
227
  getMediaCount(tweet),
215
228
  bookmarked,
216
229
  liked,
217
230
  JSON.stringify(tweet.entities ?? {}),
231
+ quotedTweetId,
218
232
  );
219
233
  upsertCollection.run(
220
234
  accountId,
@@ -0,0 +1,182 @@
1
+ import type { Database } from "./sqlite";
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 { upsertTweetAccountEdge } from "./tweet-account-edges";
7
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
8
+
9
+ const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
10
+
11
+ function parseCacheTtlMs(value?: number) {
12
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
13
+ return DEFAULT_TIMELINE_CACHE_TTL_MS;
14
+ }
15
+ return Math.floor(value);
16
+ }
17
+
18
+ function assertLimit(limit: number) {
19
+ if (!Number.isFinite(limit) || limit < 1) {
20
+ throw new Error("--limit must be at least 1");
21
+ }
22
+ }
23
+
24
+ function resolveAccount(db: Database, accountId?: string) {
25
+ const row = accountId
26
+ ? (db.prepare("select id from accounts where id = ?").get(accountId) as
27
+ | { id: string }
28
+ | undefined)
29
+ : (db
30
+ .prepare(
31
+ `
32
+ select id
33
+ from accounts
34
+ order by is_default desc, created_at asc
35
+ limit 1
36
+ `,
37
+ )
38
+ .get() as { id: string } | undefined);
39
+
40
+ if (!row) {
41
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
42
+ }
43
+
44
+ return row.id;
45
+ }
46
+
47
+ function getMediaCount(tweet: XurlMentionData) {
48
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
49
+ return urls.filter(
50
+ (url) =>
51
+ url &&
52
+ typeof url === "object" &&
53
+ typeof (url as Record<string, unknown>).media_key === "string",
54
+ ).length;
55
+ }
56
+
57
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
58
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
59
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
60
+ tweetId,
61
+ text,
62
+ );
63
+ }
64
+
65
+ function mergeHomeTimelineIntoLocalStore(
66
+ db: Database,
67
+ accountId: string,
68
+ payload: XurlMentionsResponse,
69
+ ) {
70
+ const usersById = new Map(
71
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
72
+ );
73
+ const upsertTweet = db.prepare(
74
+ `
75
+ insert into tweets (
76
+ id, account_id, author_profile_id, kind, text, created_at,
77
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
78
+ entities_json, media_json, quoted_tweet_id
79
+ ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
80
+ on conflict(id) do update set
81
+ account_id = tweets.account_id,
82
+ author_profile_id = excluded.author_profile_id,
83
+ kind = tweets.kind,
84
+ text = excluded.text,
85
+ created_at = excluded.created_at,
86
+ like_count = excluded.like_count,
87
+ media_count = excluded.media_count,
88
+ entities_json = excluded.entities_json,
89
+ media_json = excluded.media_json,
90
+ bookmarked = tweets.bookmarked,
91
+ liked = tweets.liked
92
+ `,
93
+ );
94
+
95
+ db.transaction(() => {
96
+ const seenAt = new Date().toISOString();
97
+ for (const tweet of payload.data) {
98
+ const author =
99
+ usersById.get(tweet.author_id) ??
100
+ ({
101
+ id: tweet.author_id,
102
+ username: `user_${tweet.author_id}`,
103
+ name: `user_${tweet.author_id}`,
104
+ } as const);
105
+ const profile = usersById.has(tweet.author_id)
106
+ ? upsertProfileFromXUser(db, author)
107
+ : ensureStubProfileForXUser(db, tweet.author_id);
108
+ upsertTweet.run(
109
+ tweet.id,
110
+ accountId,
111
+ profile.profile.id,
112
+ tweet.text,
113
+ tweet.created_at,
114
+ Number(tweet.public_metrics?.like_count ?? 0),
115
+ getMediaCount(tweet),
116
+ JSON.stringify(tweet.entities ?? {}),
117
+ );
118
+ upsertTweetAccountEdge(db, {
119
+ accountId,
120
+ tweetId: tweet.id,
121
+ kind: "home",
122
+ source: "bird",
123
+ seenAt,
124
+ rawJson: JSON.stringify(tweet),
125
+ });
126
+ replaceTweetFts(db, tweet.id, tweet.text);
127
+ }
128
+ })();
129
+ }
130
+
131
+ export async function syncHomeTimeline({
132
+ account,
133
+ limit = 100,
134
+ following = true,
135
+ refresh = false,
136
+ cacheTtlMs,
137
+ }: {
138
+ account?: string;
139
+ limit?: number;
140
+ following?: boolean;
141
+ refresh?: boolean;
142
+ cacheTtlMs?: number;
143
+ }) {
144
+ assertLimit(limit);
145
+ const db = getNativeDb();
146
+ const accountId = resolveAccount(db, account);
147
+ const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
148
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
149
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
150
+ const cacheAgeMs = cached
151
+ ? Date.now() - new Date(cached.updatedAt).getTime()
152
+ : Number.POSITIVE_INFINITY;
153
+
154
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
155
+ return {
156
+ ok: true,
157
+ source: "cache",
158
+ kind: "timeline",
159
+ accountId,
160
+ feed: following ? "following" : "for-you",
161
+ count: cached.value.data.length,
162
+ payload: cached.value,
163
+ };
164
+ }
165
+
166
+ const payload = await listHomeTimelineViaBird({
167
+ maxResults: limit,
168
+ following,
169
+ });
170
+ mergeHomeTimelineIntoLocalStore(db, accountId, payload);
171
+ writeSyncCache(cacheKey, payload, db);
172
+
173
+ return {
174
+ ok: true,
175
+ source: "bird",
176
+ kind: "timeline",
177
+ accountId,
178
+ feed: following ? "following" : "for-you",
179
+ count: payload.data.length,
180
+ payload,
181
+ };
182
+ }
@@ -0,0 +1,39 @@
1
+ import type { Database } from "./sqlite";
2
+
3
+ export type TweetAccountEdgeKind = "home" | "mention";
4
+
5
+ export function upsertTweetAccountEdge(
6
+ db: Database,
7
+ {
8
+ accountId,
9
+ tweetId,
10
+ kind,
11
+ source,
12
+ seenAt,
13
+ rawJson = "{}",
14
+ }: {
15
+ accountId: string;
16
+ tweetId: string;
17
+ kind: TweetAccountEdgeKind;
18
+ source: string;
19
+ seenAt: string;
20
+ rawJson?: string;
21
+ },
22
+ ) {
23
+ db.prepare(`
24
+ insert into tweet_account_edges (
25
+ account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
26
+ source, raw_json, updated_at
27
+ ) values (?, ?, ?, ?, ?, 1, ?, ?, ?)
28
+ on conflict(account_id, tweet_id, kind) do update set
29
+ first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
30
+ last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
31
+ seen_count = tweet_account_edges.seen_count + 1,
32
+ source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
33
+ raw_json = case
34
+ when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
35
+ else tweet_account_edges.raw_json
36
+ end,
37
+ updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
38
+ `).run(accountId, tweetId, kind, seenAt, seenAt, source, rawJson, seenAt);
39
+ }
@@ -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,11 +20,58 @@ 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;
26
+ location?: string;
27
+ url?: string;
28
+ verifiedType?: string;
29
+ entities?: Record<string, unknown>;
30
+ affiliations?: ProfileAffiliation[];
31
+ primaryAffiliation?: ProfileAffiliation;
25
32
  createdAt: string;
26
33
  }
27
34
 
35
+ export interface ProfileAffiliation {
36
+ organizationProfileId: string;
37
+ organizationName?: string;
38
+ organizationHandle?: string;
39
+ badgeUrl?: string | null;
40
+ url?: string | null;
41
+ label?: string | null;
42
+ source: string;
43
+ firstSeenAt: string;
44
+ lastSeenAt: string;
45
+ isActive: boolean;
46
+ }
47
+
48
+ export interface ProfileSnapshot {
49
+ profileId: string;
50
+ snapshotHash: string;
51
+ observedAt: string;
52
+ lastSeenAt: string;
53
+ source: string;
54
+ handle: string;
55
+ displayName: string;
56
+ bio: string;
57
+ location: string | null;
58
+ url: string | null;
59
+ verifiedType: string | null;
60
+ followersCount: number;
61
+ followingCount: number;
62
+ affiliations: unknown[];
63
+ }
64
+
65
+ export interface ProfileBioEntity {
66
+ profileId: string;
67
+ kind: "handle" | "domain" | "company_phrase";
68
+ value: string;
69
+ source: string;
70
+ firstSeenAt: string;
71
+ lastSeenAt: string;
72
+ isActive: boolean;
73
+ }
74
+
28
75
  export interface TweetMentionEntity {
29
76
  username: string;
30
77
  id?: string;
@@ -98,6 +145,7 @@ export interface TimelineItem {
98
145
  accountHandle: string;
99
146
  kind: "home" | "mention" | "like" | "bookmark";
100
147
  text: string;
148
+ searchSnippet?: string;
101
149
  createdAt: string;
102
150
  isReplied: boolean;
103
151
  likeCount: number;
@@ -109,6 +157,7 @@ export interface TimelineItem {
109
157
  media: TweetMediaItem[];
110
158
  replyToTweet?: EmbeddedTweet | null;
111
159
  quotedTweet?: EmbeddedTweet | null;
160
+ qualityReason?: string | null;
112
161
  }
113
162
 
114
163
  export interface DmMessageItem {
@@ -122,11 +171,31 @@ export interface DmMessageItem {
122
171
  sender: ProfileRecord;
123
172
  }
124
173
 
174
+ export interface UrlExpansionItem {
175
+ url: string;
176
+ expandedUrl: string;
177
+ finalUrl: string;
178
+ status: "hit" | "miss" | "error";
179
+ source: "cache" | "network";
180
+ title?: string;
181
+ description?: string | null;
182
+ error?: string;
183
+ updatedAt: string;
184
+ }
185
+
186
+ export interface DmSearchMatchItem {
187
+ message: DmMessageItem;
188
+ before: DmMessageItem[];
189
+ after: DmMessageItem[];
190
+ urlExpansions?: UrlExpansionItem[];
191
+ }
192
+
125
193
  export interface DmConversationItem {
126
194
  id: string;
127
195
  accountId: string;
128
196
  accountHandle: string;
129
197
  title: string;
198
+ searchSnippet?: string;
130
199
  lastMessageAt: string;
131
200
  lastMessagePreview: string;
132
201
  unreadCount: number;
@@ -134,6 +203,7 @@ export interface DmConversationItem {
134
203
  influenceScore: number;
135
204
  influenceLabel: string;
136
205
  participant: ProfileRecord;
206
+ matches?: DmSearchMatchItem[];
137
207
  }
138
208
 
139
209
  export interface TimelineQuery {
@@ -145,6 +215,8 @@ export interface TimelineQuery {
145
215
  until?: string;
146
216
  includeReplies?: boolean;
147
217
  qualityFilter?: TimelineQualityFilter;
218
+ lowQualityThreshold?: number;
219
+ includeQualityReason?: boolean;
148
220
  likedOnly?: boolean;
149
221
  bookmarkedOnly?: boolean;
150
222
  limit?: number;
@@ -152,6 +224,7 @@ export interface TimelineQuery {
152
224
 
153
225
  export interface DmQuery {
154
226
  account?: string;
227
+ conversationIds?: string[];
155
228
  participant?: string;
156
229
  search?: string;
157
230
  replyFilter?: ReplyFilter;
@@ -160,6 +233,7 @@ export interface DmQuery {
160
233
  minInfluenceScore?: number;
161
234
  maxInfluenceScore?: number;
162
235
  sort?: "recent" | "influence";
236
+ context?: number;
163
237
  limit?: number;
164
238
  }
165
239
 
@@ -171,7 +245,7 @@ export interface TransportStatus {
171
245
  }
172
246
 
173
247
  export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
174
- export type ModerationTransportKind = "bird" | "xurl";
248
+ export type ModerationTransportKind = "bird" | "xurl" | "x-web";
175
249
 
176
250
  export interface ModerationActionTransportResult {
177
251
  ok: boolean;
@@ -252,6 +326,7 @@ export interface XurlPublicMetrics {
252
326
  bookmark_count?: number;
253
327
  impression_count?: number;
254
328
  followers_count?: number;
329
+ following_count?: number;
255
330
  }
256
331
 
257
332
  export interface XurlMentionUser {
@@ -259,7 +334,13 @@ export interface XurlMentionUser {
259
334
  name: string;
260
335
  username: string;
261
336
  description?: string;
337
+ location?: string;
338
+ url?: string;
339
+ verified?: boolean;
340
+ verified_type?: string;
262
341
  profile_image_url?: string;
342
+ entities?: Record<string, unknown>;
343
+ affiliation?: Record<string, unknown>;
263
344
  public_metrics?: XurlPublicMetrics;
264
345
  created_at?: string;
265
346
  }
@@ -271,6 +352,7 @@ export interface XurlMentionData {
271
352
  created_at: string;
272
353
  conversation_id?: string;
273
354
  entities?: Record<string, unknown>;
355
+ referenced_tweets?: XurlReferencedTweet[];
274
356
  public_metrics?: XurlPublicMetrics;
275
357
  edit_history_tweet_ids?: string[];
276
358
  }
@@ -290,6 +372,18 @@ export interface XurlUserTweet {
290
372
  edit_history_tweet_ids?: string[];
291
373
  }
292
374
 
375
+ export interface XurlTweetData {
376
+ id: string;
377
+ author_id: string;
378
+ text: string;
379
+ created_at: string;
380
+ conversation_id?: string;
381
+ entities?: Record<string, unknown>;
382
+ referenced_tweets?: XurlReferencedTweet[];
383
+ public_metrics?: XurlPublicMetrics;
384
+ edit_history_tweet_ids?: string[];
385
+ }
386
+
293
387
  export interface ProfileReplyItem {
294
388
  id: string;
295
389
  text: string;
@@ -322,3 +416,11 @@ export interface XurlMentionsResponse {
322
416
  };
323
417
  meta?: Record<string, unknown>;
324
418
  }
419
+
420
+ export interface XurlTweetsResponse {
421
+ data: XurlTweetData[];
422
+ includes?: {
423
+ users?: XurlMentionUser[];
424
+ };
425
+ meta?: Record<string, unknown>;
426
+ }