birdclaw 0.3.0 → 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.
@@ -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,
@@ -150,7 +150,7 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
150
150
  }
151
151
 
152
152
  function mergeTimelineCollectionIntoLocalStore(
153
- db: Database.Database,
153
+ db: Database,
154
154
  accountId: string,
155
155
  kind: TimelineCollectionKind,
156
156
  payload: XurlMentionsResponse,
@@ -170,7 +170,7 @@ function mergeTimelineCollectionIntoLocalStore(
170
170
  entities_json, media_json, quoted_tweet_id
171
171
  ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
172
172
  on conflict(id) do update set
173
- account_id = excluded.account_id,
173
+ account_id = tweets.account_id,
174
174
  author_profile_id = excluded.author_profile_id,
175
175
  kind = case
176
176
  when tweets.kind in ('home', 'mention') then tweets.kind
@@ -185,8 +185,8 @@ function mergeTimelineCollectionIntoLocalStore(
185
185
  is_replied = max(tweets.is_replied, excluded.is_replied),
186
186
  reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
187
187
  quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
188
- bookmarked = max(tweets.bookmarked, excluded.bookmarked),
189
- liked = max(tweets.liked, excluded.liked)
188
+ bookmarked = tweets.bookmarked,
189
+ liked = tweets.liked
190
190
  `,
191
191
  );
192
192
  const upsertCollection = db.prepare(`
@@ -1,8 +1,9 @@
1
- import type Database from "better-sqlite3";
1
+ import type { Database } from "./sqlite";
2
2
  import { listHomeTimelineViaBird } from "./bird";
3
3
  import { getNativeDb } from "./db";
4
4
  import { readSyncCache, writeSyncCache } from "./sync-cache";
5
5
  import type { XurlMentionData, XurlMentionsResponse } from "./types";
6
+ import { upsertTweetAccountEdge } from "./tweet-account-edges";
6
7
  import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
7
8
 
8
9
  const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
@@ -20,7 +21,7 @@ function assertLimit(limit: number) {
20
21
  }
21
22
  }
22
23
 
23
- function resolveAccount(db: Database.Database, accountId?: string) {
24
+ function resolveAccount(db: Database, accountId?: string) {
24
25
  const row = accountId
25
26
  ? (db.prepare("select id from accounts where id = ?").get(accountId) as
26
27
  | { id: string }
@@ -53,7 +54,7 @@ function getMediaCount(tweet: XurlMentionData) {
53
54
  ).length;
54
55
  }
55
56
 
56
- function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
57
+ function replaceTweetFts(db: Database, tweetId: string, text: string) {
57
58
  db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
58
59
  db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
59
60
  tweetId,
@@ -62,7 +63,7 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
62
63
  }
63
64
 
64
65
  function mergeHomeTimelineIntoLocalStore(
65
- db: Database.Database,
66
+ db: Database,
66
67
  accountId: string,
67
68
  payload: XurlMentionsResponse,
68
69
  ) {
@@ -77,12 +78,9 @@ function mergeHomeTimelineIntoLocalStore(
77
78
  entities_json, media_json, quoted_tweet_id
78
79
  ) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
79
80
  on conflict(id) do update set
80
- account_id = excluded.account_id,
81
+ account_id = tweets.account_id,
81
82
  author_profile_id = excluded.author_profile_id,
82
- kind = case
83
- when tweets.kind = 'mention' then tweets.kind
84
- else 'home'
85
- end,
83
+ kind = tweets.kind,
86
84
  text = excluded.text,
87
85
  created_at = excluded.created_at,
88
86
  like_count = excluded.like_count,
@@ -95,6 +93,7 @@ function mergeHomeTimelineIntoLocalStore(
95
93
  );
96
94
 
97
95
  db.transaction(() => {
96
+ const seenAt = new Date().toISOString();
98
97
  for (const tweet of payload.data) {
99
98
  const author =
100
99
  usersById.get(tweet.author_id) ??
@@ -116,6 +115,14 @@ function mergeHomeTimelineIntoLocalStore(
116
115
  getMediaCount(tweet),
117
116
  JSON.stringify(tweet.entities ?? {}),
118
117
  );
118
+ upsertTweetAccountEdge(db, {
119
+ accountId,
120
+ tweetId: tweet.id,
121
+ kind: "home",
122
+ source: "bird",
123
+ seenAt,
124
+ rawJson: JSON.stringify(tweet),
125
+ });
119
126
  replaceTweetFts(db, tweet.id, tweet.text);
120
127
  }
121
128
  })();
@@ -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
+ }
package/src/lib/types.ts CHANGED
@@ -23,9 +23,55 @@ export interface ProfileRecord {
23
23
  followingCount?: number;
24
24
  avatarHue: number;
25
25
  avatarUrl?: string;
26
+ location?: string;
27
+ url?: string;
28
+ verifiedType?: string;
29
+ entities?: Record<string, unknown>;
30
+ affiliations?: ProfileAffiliation[];
31
+ primaryAffiliation?: ProfileAffiliation;
26
32
  createdAt: string;
27
33
  }
28
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
+
29
75
  export interface TweetMentionEntity {
30
76
  username: string;
31
77
  id?: string;
@@ -125,6 +171,25 @@ export interface DmMessageItem {
125
171
  sender: ProfileRecord;
126
172
  }
127
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
+
128
193
  export interface DmConversationItem {
129
194
  id: string;
130
195
  accountId: string;
@@ -138,6 +203,7 @@ export interface DmConversationItem {
138
203
  influenceScore: number;
139
204
  influenceLabel: string;
140
205
  participant: ProfileRecord;
206
+ matches?: DmSearchMatchItem[];
141
207
  }
142
208
 
143
209
  export interface TimelineQuery {
@@ -158,6 +224,7 @@ export interface TimelineQuery {
158
224
 
159
225
  export interface DmQuery {
160
226
  account?: string;
227
+ conversationIds?: string[];
161
228
  participant?: string;
162
229
  search?: string;
163
230
  replyFilter?: ReplyFilter;
@@ -166,6 +233,7 @@ export interface DmQuery {
166
233
  minInfluenceScore?: number;
167
234
  maxInfluenceScore?: number;
168
235
  sort?: "recent" | "influence";
236
+ context?: number;
169
237
  limit?: number;
170
238
  }
171
239
 
@@ -266,7 +334,13 @@ export interface XurlMentionUser {
266
334
  name: string;
267
335
  username: string;
268
336
  description?: string;
337
+ location?: string;
338
+ url?: string;
339
+ verified?: boolean;
340
+ verified_type?: string;
269
341
  profile_image_url?: string;
342
+ entities?: Record<string, unknown>;
343
+ affiliation?: Record<string, unknown>;
270
344
  public_metrics?: XurlPublicMetrics;
271
345
  created_at?: string;
272
346
  }
@@ -0,0 +1,147 @@
1
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
2
+ import type { UrlExpansionItem } from "./types";
3
+
4
+ const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
5
+ const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
6
+ const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
7
+
8
+ interface CachedUrlExpansion {
9
+ expandedUrl: string;
10
+ finalUrl: string;
11
+ status: UrlExpansionItem["status"];
12
+ title?: string;
13
+ description?: string | null;
14
+ error?: string;
15
+ }
16
+
17
+ export interface ExpandUrlsOptions {
18
+ refresh?: boolean;
19
+ successMaxAgeMs?: number;
20
+ failureMaxAgeMs?: number;
21
+ fetchImpl?: typeof fetch;
22
+ }
23
+
24
+ function cacheKeyForUrl(url: string) {
25
+ return `url:expand:${url}`;
26
+ }
27
+
28
+ function isFresh(updatedAt: string, maxAgeMs: number) {
29
+ return Date.now() - new Date(updatedAt).getTime() <= maxAgeMs;
30
+ }
31
+
32
+ function trimTrailingPunctuation(url: string) {
33
+ return url.replace(/[.,;:!?]+$/g, "");
34
+ }
35
+
36
+ export function extractUrls(text: string) {
37
+ return Array.from(
38
+ new Set(
39
+ Array.from(text.matchAll(URL_REGEX), (match) =>
40
+ trimTrailingPunctuation(match[0]),
41
+ ),
42
+ ),
43
+ ).filter((url) => url.length > 0);
44
+ }
45
+
46
+ function toExpansionItem(
47
+ url: string,
48
+ value: CachedUrlExpansion,
49
+ source: UrlExpansionItem["source"],
50
+ updatedAt: string,
51
+ ): UrlExpansionItem {
52
+ return {
53
+ url,
54
+ expandedUrl: value.expandedUrl,
55
+ finalUrl: value.finalUrl,
56
+ status: value.status,
57
+ source,
58
+ ...(value.title ? { title: value.title } : {}),
59
+ ...(value.description !== undefined
60
+ ? { description: value.description }
61
+ : {}),
62
+ ...(value.error ? { error: value.error } : {}),
63
+ updatedAt,
64
+ };
65
+ }
66
+
67
+ async function fetchExpansion(
68
+ url: string,
69
+ fetchImpl: typeof fetch,
70
+ ): Promise<CachedUrlExpansion> {
71
+ try {
72
+ let response = await fetchImpl(url, {
73
+ method: "HEAD",
74
+ redirect: "follow",
75
+ headers: { "user-agent": "birdclaw/0.3 url-expander" },
76
+ });
77
+
78
+ if (!response.url || response.url === url || response.status >= 400) {
79
+ response = await fetchImpl(url, {
80
+ method: "GET",
81
+ redirect: "follow",
82
+ headers: { "user-agent": "birdclaw/0.3 url-expander" },
83
+ });
84
+ }
85
+
86
+ const finalUrl = response.url || url;
87
+ return {
88
+ expandedUrl: finalUrl,
89
+ finalUrl,
90
+ status: response.ok || finalUrl !== url ? "hit" : "miss",
91
+ ...(response.ok ? {} : { error: `HTTP ${response.status}` }),
92
+ };
93
+ } catch (error) {
94
+ return {
95
+ expandedUrl: url,
96
+ finalUrl: url,
97
+ status: "error",
98
+ error: error instanceof Error ? error.message : String(error),
99
+ };
100
+ }
101
+ }
102
+
103
+ export async function expandUrls(
104
+ urls: string[],
105
+ options: ExpandUrlsOptions = {},
106
+ ): Promise<UrlExpansionItem[]> {
107
+ const uniqueUrls = Array.from(new Set(urls));
108
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
109
+ const results: UrlExpansionItem[] = [];
110
+
111
+ for (const url of uniqueUrls) {
112
+ const cached = readSyncCache<CachedUrlExpansion>(cacheKeyForUrl(url));
113
+ if (cached && !options.refresh) {
114
+ const maxAge =
115
+ cached.value.status === "hit"
116
+ ? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
117
+ : (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
118
+ if (isFresh(cached.updatedAt, maxAge)) {
119
+ results.push(
120
+ toExpansionItem(url, cached.value, "cache", cached.updatedAt),
121
+ );
122
+ continue;
123
+ }
124
+ }
125
+
126
+ const value = await fetchExpansion(url, fetchImpl);
127
+ const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
128
+ results.push(toExpansionItem(url, value, "network", updatedAt));
129
+ }
130
+
131
+ return results;
132
+ }
133
+
134
+ export async function expandUrlsFromTexts(
135
+ texts: string[],
136
+ options: ExpandUrlsOptions = {},
137
+ ) {
138
+ return expandUrls(
139
+ texts.flatMap((text) => extractUrls(text)),
140
+ options,
141
+ );
142
+ }
143
+
144
+ export const __test__ = {
145
+ cacheKeyForUrl,
146
+ trimTrailingPunctuation,
147
+ };