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/CHANGELOG.md +25 -1
- package/README.md +18 -7
- package/package.json +11 -10
- package/playwright.config.ts +5 -2
- package/src/cli.ts +113 -0
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +9 -2
- package/src/lib/backup.ts +5 -3
- package/src/lib/bird-actions.ts +4 -0
- package/src/lib/bird.ts +143 -17
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +8 -0
- package/src/lib/mention-threads-live.ts +271 -0
- package/src/lib/moderation-target.ts +3 -1
- package/src/lib/profile-hydration.ts +8 -0
- package/src/lib/queries.ts +135 -15
- package/src/lib/research.ts +596 -0
- package/src/lib/seed.ts +8 -2
- package/src/lib/timeline-collections-live.ts +15 -1
- package/src/lib/timeline-live.ts +175 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +29 -1
- package/src/lib/x-profile.ts +41 -12
- package/src/lib/xurl.ts +35 -1
|
@@ -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
|
+
}
|
package/src/lib/x-profile.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
|
@@ -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":
|
|
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
|
}
|