birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -0
@@ -1,5 +1,7 @@
1
1
  import type { Database } from "./sqlite";
2
- import { lookupProfileViaBird } from "./bird";
2
+ import { Effect } from "effect";
3
+ import { lookupProfileViaBirdEffect } from "./bird";
4
+ import { runEffectPromise } from "./effect-runtime";
3
5
  import { syncIdentitySearchIndexForProfileIds } from "./identity-search-index";
4
6
  import { syncProfileBioEntitiesForProfileId } from "./profile-bio-entities";
5
7
  import { recordProfileSnapshot } from "./profile-history";
@@ -24,6 +26,17 @@ interface SyntheticAffiliationRow {
24
26
  raw_json: string;
25
27
  }
26
28
 
29
+ function toError(error: unknown) {
30
+ return error instanceof Error ? error : new Error(String(error));
31
+ }
32
+
33
+ function trySync<T>(try_: () => T) {
34
+ return Effect.try({
35
+ try: try_,
36
+ catch: toError,
37
+ });
38
+ }
39
+
27
40
  function normalizeHandle(value: string | null) {
28
41
  const handle = value?.trim().replace(/^@/, "");
29
42
  return handle && handle.length > 0 ? handle : null;
@@ -112,13 +125,16 @@ function findLocalOrganizationProfileId(db: Database, handle: string) {
112
125
  return row?.id ?? null;
113
126
  }
114
127
 
115
- export async function hydrateProfileAffiliationOrganizations(
128
+ export function hydrateProfileAffiliationOrganizationsEffect(
116
129
  db: Database,
117
130
  subjectProfileId: string,
118
- ): Promise<ProfileAffiliationHydrationResult> {
119
- const rows = db
120
- .prepare(
121
- `
131
+ ): Effect.Effect<ProfileAffiliationHydrationResult, unknown> {
132
+ return Effect.gen(function* () {
133
+ const rows = yield* trySync(
134
+ () =>
135
+ db
136
+ .prepare(
137
+ `
122
138
  select subject_profile_id, organization_profile_id, organization_name,
123
139
  organization_handle, badge_url, url, label, source, raw_json
124
140
  from profile_affiliations
@@ -128,62 +144,85 @@ export async function hydrateProfileAffiliationOrganizations(
128
144
  and organization_handle is not null
129
145
  order by last_seen_at desc
130
146
  `,
131
- )
132
- .all(subjectProfileId) as SyntheticAffiliationRow[];
133
-
134
- const result: ProfileAffiliationHydrationResult = {
135
- checked: rows.length,
136
- hydrated: 0,
137
- skipped: 0,
138
- errors: [],
139
- };
140
-
141
- for (const row of rows) {
142
- const handle = normalizeHandle(row.organization_handle);
143
- if (!handle) {
144
- result.skipped += 1;
145
- continue;
146
- }
147
- try {
148
- const localOrganizationProfileId = findLocalOrganizationProfileId(
149
- db,
150
- handle,
151
- );
152
- if (localOrganizationProfileId) {
153
- db.transaction(() => {
154
- replaceSyntheticAffiliation(db, row, localOrganizationProfileId);
155
- })();
156
- result.hydrated += 1;
157
- continue;
158
- }
147
+ )
148
+ .all(subjectProfileId) as SyntheticAffiliationRow[],
149
+ );
159
150
 
160
- const user = await lookupProfileViaBird(handle);
161
- if (!user) {
151
+ const result: ProfileAffiliationHydrationResult = {
152
+ checked: rows.length,
153
+ hydrated: 0,
154
+ skipped: 0,
155
+ errors: [],
156
+ };
157
+
158
+ for (const row of rows) {
159
+ const handle = normalizeHandle(row.organization_handle);
160
+ if (!handle) {
162
161
  result.skipped += 1;
163
162
  continue;
164
163
  }
165
- const resolved = upsertProfileFromXUser(db, user);
166
- if (resolved.profile.id === row.organization_profile_id) {
167
- result.skipped += 1;
164
+ const hydrated = yield* Effect.gen(function* () {
165
+ const localOrganizationProfileId = yield* trySync(() =>
166
+ findLocalOrganizationProfileId(db, handle),
167
+ );
168
+ if (localOrganizationProfileId) {
169
+ yield* trySync(() =>
170
+ db.transaction(() => {
171
+ replaceSyntheticAffiliation(db, row, localOrganizationProfileId);
172
+ })(),
173
+ );
174
+ result.hydrated += 1;
175
+ return true;
176
+ }
177
+
178
+ const user = yield* lookupProfileViaBirdEffect(handle);
179
+ if (!user) {
180
+ result.skipped += 1;
181
+ return true;
182
+ }
183
+ const resolved = yield* trySync(() => upsertProfileFromXUser(db, user));
184
+ if (resolved.profile.id === row.organization_profile_id) {
185
+ result.skipped += 1;
186
+ return true;
187
+ }
188
+ yield* trySync(() =>
189
+ db.transaction(() => {
190
+ replaceSyntheticAffiliation(db, row, resolved.profile.id);
191
+ })(),
192
+ );
193
+ result.hydrated += 1;
194
+ return true;
195
+ }).pipe(
196
+ Effect.catchAll((error) => {
197
+ result.errors.push({
198
+ handle,
199
+ error: error instanceof Error ? error.message : String(error),
200
+ });
201
+ return Effect.succeed(false);
202
+ }),
203
+ );
204
+ if (!hydrated) {
168
205
  continue;
169
206
  }
170
- db.transaction(() => {
171
- replaceSyntheticAffiliation(db, row, resolved.profile.id);
172
- })();
173
- result.hydrated += 1;
174
- } catch (error) {
175
- result.errors.push({
176
- handle,
177
- error: error instanceof Error ? error.message : String(error),
207
+ }
208
+
209
+ if (result.hydrated > 0) {
210
+ yield* trySync(() => {
211
+ recordProfileSnapshot(db, subjectProfileId, "affiliation_hydration");
212
+ syncProfileBioEntitiesForProfileId(db, subjectProfileId);
213
+ syncIdentitySearchIndexForProfileIds(db, [subjectProfileId]);
178
214
  });
179
215
  }
180
- }
181
216
 
182
- if (result.hydrated > 0) {
183
- recordProfileSnapshot(db, subjectProfileId, "affiliation_hydration");
184
- syncProfileBioEntitiesForProfileId(db, subjectProfileId);
185
- syncIdentitySearchIndexForProfileIds(db, [subjectProfileId]);
186
- }
217
+ return result;
218
+ });
219
+ }
187
220
 
188
- return result;
221
+ export function hydrateProfileAffiliationOrganizations(
222
+ db: Database,
223
+ subjectProfileId: string,
224
+ ): Promise<ProfileAffiliationHydrationResult> {
225
+ return runEffectPromise(
226
+ hydrateProfileAffiliationOrganizationsEffect(db, subjectProfileId),
227
+ );
189
228
  }
@@ -1,5 +1,8 @@
1
+ import { Effect } from "effect";
2
+
1
3
  import { normalizeAvatarUrl } from "./avatar-cache";
2
4
  import { getNativeDb } from "./db";
5
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
3
6
  import {
4
7
  getTransportStatus,
5
8
  lookupAuthenticatedUser,
@@ -7,6 +10,13 @@ import {
7
10
  } from "./xurl";
8
11
  import { upsertProfileFromXUser } from "./x-profile";
9
12
 
13
+ export type HydrateProfilesResult = {
14
+ ok: true;
15
+ hydratedProfiles: number;
16
+ hydratedAccount: boolean;
17
+ reason?: string;
18
+ };
19
+
10
20
  function asRecord(value: unknown): Record<string, unknown> | null {
11
21
  return value && typeof value === "object" && !Array.isArray(value)
12
22
  ? (value as Record<string, unknown>)
@@ -18,40 +28,62 @@ function toInt(value: unknown) {
18
28
  return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
19
29
  }
20
30
 
21
- export async function hydrateProfilesFromX() {
22
- const transport = await getTransportStatus();
23
- if (transport.availableTransport !== "xurl") {
24
- return {
25
- ok: true,
26
- hydratedProfiles: 0,
27
- hydratedAccount: false,
28
- reason: transport.statusText,
29
- };
30
- }
31
+ function toError(error: unknown) {
32
+ return error instanceof Error ? error : new Error(String(error));
33
+ }
34
+
35
+ function trySync<T>(try_: () => T) {
36
+ return Effect.try({
37
+ try: try_,
38
+ catch: toError,
39
+ });
40
+ }
31
41
 
32
- const db = getNativeDb();
33
- const candidateRows = db
34
- .prepare(
35
- `
42
+ export function hydrateProfilesFromXEffect(): Effect.Effect<
43
+ HydrateProfilesResult,
44
+ unknown
45
+ > {
46
+ return Effect.gen(function* () {
47
+ const transport = yield* tryPromise(() => getTransportStatus());
48
+ if (transport.availableTransport !== "xurl") {
49
+ return {
50
+ ok: true,
51
+ hydratedProfiles: 0,
52
+ hydratedAccount: false,
53
+ reason: transport.statusText,
54
+ };
55
+ }
56
+
57
+ const {
58
+ candidateIds,
59
+ db,
60
+ updateAccount,
61
+ updateConversationTitle,
62
+ updateLocalProfile,
63
+ } = yield* trySync(() => {
64
+ const db = getNativeDb();
65
+ const candidateRows = db
66
+ .prepare(
67
+ `
36
68
  select id
37
69
  from profiles
38
70
  where id like 'profile_user_%'
39
71
  and (followers_count = 0 or bio like 'Imported from archive user %' or handle like 'id%')
40
72
  order by id asc
41
73
  `,
42
- )
43
- .all() as Array<{ id: string }>;
74
+ )
75
+ .all() as Array<{ id: string }>;
44
76
 
45
- const candidateIds = candidateRows
46
- .map((row) => row.id.replace(/^profile_user_/, ""))
47
- .filter((id) => /^\d+$/.test(id));
77
+ const candidateIds = candidateRows
78
+ .map((row) => row.id.replace(/^profile_user_/, ""))
79
+ .filter((id) => /^\d+$/.test(id));
48
80
 
49
- const updateConversationTitle = db.prepare(`
81
+ const updateConversationTitle = db.prepare(`
50
82
  update dm_conversations
51
83
  set title = ?
52
84
  where participant_profile_id = ?
53
85
  `);
54
- const updateLocalProfile = db.prepare(`
86
+ const updateLocalProfile = db.prepare(`
55
87
  update profiles
56
88
  set handle = ?,
57
89
  display_name = ?,
@@ -62,7 +94,7 @@ export async function hydrateProfilesFromX() {
62
94
  created_at = coalesce(?, created_at)
63
95
  where id = 'profile_me'
64
96
  `);
65
- const updateAccount = db.prepare(`
97
+ const updateAccount = db.prepare(`
66
98
  update accounts
67
99
  set name = ?,
68
100
  handle = ?,
@@ -70,56 +102,76 @@ export async function hydrateProfilesFromX() {
70
102
  where id = 'acct_primary'
71
103
  `);
72
104
 
73
- let hydratedProfiles = 0;
74
-
75
- for (let index = 0; index < candidateIds.length; index += 100) {
76
- const batch = candidateIds.slice(index, index + 100);
77
- const users = await lookupUsersByIds(batch);
78
-
79
- db.transaction(() => {
80
- for (const user of users) {
81
- const profileId = `profile_user_${String(user.id ?? "")}`;
82
- if (profileId === "profile_user_") continue;
83
-
84
- const resolved = upsertProfileFromXUser(db, user);
85
- updateConversationTitle.run(
86
- resolved.profile.displayName || resolved.profile.handle,
87
- resolved.profile.id,
88
- );
89
- hydratedProfiles += 1;
90
- }
91
- })();
92
- }
93
-
94
- let hydratedAccount = false;
95
- const me = await lookupAuthenticatedUser().catch(() => null);
96
- if (me) {
97
- const metrics = asRecord(me.public_metrics);
98
- db.transaction(() => {
99
- updateLocalProfile.run(
100
- String(me.username ?? "steipete").replace(/^@/, ""),
101
- String(me.name ?? "Peter Steinberger"),
102
- String(me.description ?? ""),
103
- toInt(metrics?.followers_count),
104
- metrics && "following_count" in metrics
105
- ? toInt(metrics.following_count)
106
- : null,
107
- normalizeAvatarUrl(me.profile_image_url),
108
- typeof me.created_at === "string" ? me.created_at : null,
109
- );
110
- updateAccount.run(
111
- String(me.name ?? "Peter Steinberger"),
112
- `@${String(me.username ?? "steipete").replace(/^@/, "")}`,
113
- );
114
- })();
115
- hydratedAccount = true;
116
- }
117
-
118
- return {
119
- ok: true,
120
- hydratedProfiles,
121
- hydratedAccount,
122
- };
105
+ return {
106
+ candidateIds,
107
+ db,
108
+ updateAccount,
109
+ updateConversationTitle,
110
+ updateLocalProfile,
111
+ };
112
+ });
113
+
114
+ let hydratedProfiles = 0;
115
+
116
+ for (let index = 0; index < candidateIds.length; index += 100) {
117
+ const batch = candidateIds.slice(index, index + 100);
118
+ const users = yield* tryPromise(() => lookupUsersByIds(batch));
119
+
120
+ yield* trySync(() => {
121
+ db.transaction(() => {
122
+ for (const user of users) {
123
+ const profileId = `profile_user_${String(user.id ?? "")}`;
124
+ if (profileId === "profile_user_") continue;
125
+
126
+ const resolved = upsertProfileFromXUser(db, user);
127
+ updateConversationTitle.run(
128
+ resolved.profile.displayName || resolved.profile.handle,
129
+ resolved.profile.id,
130
+ );
131
+ hydratedProfiles += 1;
132
+ }
133
+ })();
134
+ });
135
+ }
136
+
137
+ let hydratedAccount = false;
138
+ const me = yield* tryPromise(() => lookupAuthenticatedUser()).pipe(
139
+ Effect.catchAll(() => Effect.succeed(null)),
140
+ );
141
+ if (me) {
142
+ const metrics = asRecord(me.public_metrics);
143
+ yield* trySync(() => {
144
+ db.transaction(() => {
145
+ updateLocalProfile.run(
146
+ String(me.username ?? "steipete").replace(/^@/, ""),
147
+ String(me.name ?? "Peter Steinberger"),
148
+ String(me.description ?? ""),
149
+ toInt(metrics?.followers_count),
150
+ metrics && "following_count" in metrics
151
+ ? toInt(metrics.following_count)
152
+ : null,
153
+ normalizeAvatarUrl(me.profile_image_url),
154
+ typeof me.created_at === "string" ? me.created_at : null,
155
+ );
156
+ updateAccount.run(
157
+ String(me.name ?? "Peter Steinberger"),
158
+ `@${String(me.username ?? "steipete").replace(/^@/, "")}`,
159
+ );
160
+ })();
161
+ });
162
+ hydratedAccount = true;
163
+ }
164
+
165
+ return {
166
+ ok: true,
167
+ hydratedProfiles,
168
+ hydratedAccount,
169
+ };
170
+ });
171
+ }
172
+
173
+ export function hydrateProfilesFromX(): Promise<HydrateProfilesResult> {
174
+ return runEffectPromise(hydrateProfilesFromXEffect());
123
175
  }
124
176
 
125
177
  export const __test__ = {
@@ -1,3 +1,6 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
1
4
  import { resolveProfile } from "./moderation-target";
2
5
  import type { ProfileRepliesResponse, XurlReferencedTweet } from "./types";
3
6
  import { listUserTweets } from "./xurl";
@@ -10,51 +13,65 @@ function getScanSize(limit: number) {
10
13
  return Math.min(Math.max(limit * 3, 20), 100);
11
14
  }
12
15
 
13
- export async function inspectProfileReplies(
16
+ export function inspectProfileReplies(
14
17
  query: string,
15
18
  { limit = 12 }: { limit?: number } = {},
16
19
  ): Promise<ProfileRepliesResponse> {
17
- const resolved = await resolveProfile(query);
18
- if (!resolved.externalUserId) {
19
- throw new Error(`Profile has no external Twitter user id: ${query}`);
20
- }
21
-
22
- const timeline = await listUserTweets(resolved.externalUserId, {
23
- maxResults: getScanSize(limit),
24
- excludeRetweets: true,
20
+ return runEffectPromise(inspectProfileRepliesEffect(query, { limit }));
21
+ }
22
+
23
+ export function inspectProfileRepliesEffect(
24
+ query: string,
25
+ { limit = 12 }: { limit?: number } = {},
26
+ ): Effect.Effect<ProfileRepliesResponse, unknown> {
27
+ return Effect.gen(function* () {
28
+ const resolved = yield* tryPromise(() => resolveProfile(query));
29
+ const externalUserId = resolved.externalUserId;
30
+ if (!externalUserId) {
31
+ return yield* Effect.fail(
32
+ new Error(`Profile has no external Twitter user id: ${query}`),
33
+ );
34
+ }
35
+
36
+ const timeline = yield* tryPromise(() =>
37
+ listUserTweets(externalUserId, {
38
+ maxResults: getScanSize(limit),
39
+ excludeRetweets: true,
40
+ }),
41
+ );
42
+ const items = timeline.items
43
+ .map((tweet) => {
44
+ const replyToTweetId = getReplyTargetId(tweet.referenced_tweets);
45
+ if (!replyToTweetId) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ id: tweet.id,
51
+ text: tweet.text,
52
+ createdAt: tweet.created_at,
53
+ conversationId: tweet.conversation_id,
54
+ replyToTweetId,
55
+ likeCount: Number(tweet.public_metrics?.like_count ?? 0),
56
+ replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
57
+ retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
58
+ quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
59
+ bookmarkCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
60
+ impressionCount: Number(tweet.public_metrics?.impression_count ?? 0),
61
+ };
62
+ })
63
+ .filter((item): item is NonNullable<typeof item> => item !== null)
64
+ .slice(0, limit);
65
+
66
+ return {
67
+ profile: resolved.profile,
68
+ externalUserId,
69
+ items,
70
+ meta: {
71
+ scannedCount: timeline.items.length,
72
+ returnedCount: items.length,
73
+ nextToken: timeline.nextToken,
74
+ },
75
+ };
25
76
  });
26
- const items = timeline.items
27
- .map((tweet) => {
28
- const replyToTweetId = getReplyTargetId(tweet.referenced_tweets);
29
- if (!replyToTweetId) {
30
- return null;
31
- }
32
-
33
- return {
34
- id: tweet.id,
35
- text: tweet.text,
36
- createdAt: tweet.created_at,
37
- conversationId: tweet.conversation_id,
38
- replyToTweetId,
39
- likeCount: Number(tweet.public_metrics?.like_count ?? 0),
40
- replyCount: Number(tweet.public_metrics?.reply_count ?? 0),
41
- retweetCount: Number(tweet.public_metrics?.retweet_count ?? 0),
42
- quoteCount: Number(tweet.public_metrics?.quote_count ?? 0),
43
- bookmarkCount: Number(tweet.public_metrics?.bookmark_count ?? 0),
44
- impressionCount: Number(tweet.public_metrics?.impression_count ?? 0),
45
- };
46
- })
47
- .filter((item): item is NonNullable<typeof item> => item !== null)
48
- .slice(0, limit);
49
-
50
- return {
51
- profile: resolved.profile,
52
- externalUserId: resolved.externalUserId,
53
- items,
54
- meta: {
55
- scannedCount: timeline.items.length,
56
- returnedCount: items.length,
57
- nextToken: timeline.nextToken,
58
- },
59
- };
60
77
  }