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
package/src/lib/xurl.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { Effect } from "effect";
4
+ import { runEffectPromise } from "./effect-runtime";
3
5
  import type {
4
6
  FollowDirection,
5
7
  TransportStatus,
8
+ XurlDmEventsResponse,
6
9
  XurlFollowUsersResponse,
7
10
  XurlMentionsResponse,
8
11
  XurlMentionUser,
@@ -21,6 +24,8 @@ const MEDIA_FIELDS =
21
24
  "variants,preview_image_url,url,duration_ms,alt_text,type,width,height,public_metrics";
22
25
  const RICH_USER_FIELDS =
23
26
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type";
27
+ const DM_EVENT_FIELDS =
28
+ "attachments,created_at,dm_conversation_id,entities,event_type,id,participant_ids,referenced_tweets,sender_id,text";
24
29
  const THREAD_TWEET_FIELDS =
25
30
  "created_at,conversation_id,entities,public_metrics,referenced_tweets,in_reply_to_user_id,attachments";
26
31
  // X bookmarks pagination truncates above 90 until this bug is fixed:
@@ -52,6 +57,13 @@ function liveWritesDisabled() {
52
57
  return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
53
58
  }
54
59
 
60
+ function e2eFakeLiveWritesEnabled() {
61
+ return (
62
+ process.env.BIRDCLAW_E2E === "1" &&
63
+ process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
64
+ );
65
+ }
66
+
55
67
  function getJsonRetryBaseDelayMs() {
56
68
  const value = Number(process.env.BIRDCLAW_XURL_RETRY_BASE_MS ?? "2000");
57
69
  return Number.isFinite(value) && value >= 0 ? value : 2000;
@@ -90,6 +102,10 @@ function formatXurlCommandError(error: unknown, args: string[]) {
90
102
  return new Error(formatExecError(error, `xurl ${args.join(" ")} failed`));
91
103
  }
92
104
 
105
+ function normalizeError(error: unknown) {
106
+ return error instanceof Error ? error : new Error(String(error));
107
+ }
108
+
93
109
  function parseErrorPayload(error: unknown) {
94
110
  const stdout =
95
111
  typeof error === "object" &&
@@ -133,13 +149,6 @@ function capTimelineCollectionMaxResults(
133
149
  : maxResults;
134
150
  }
135
151
 
136
- async function sleep(ms: number) {
137
- if (ms <= 0) {
138
- return;
139
- }
140
- await new Promise((resolve) => setTimeout(resolve, ms));
141
- }
142
-
143
152
  export function resetTransportStatusCache() {
144
153
  transportStatusCache = undefined;
145
154
  }
@@ -148,13 +157,14 @@ export function resetAuthenticatedUserCache() {
148
157
  authenticatedUserCache = undefined;
149
158
  }
150
159
 
151
- async function hasXurl(): Promise<boolean> {
152
- try {
153
- await execFileAsync("xurl", ["version"]);
154
- return true;
155
- } catch {
156
- return false;
157
- }
160
+ function hasXurlEffect() {
161
+ return Effect.tryPromise({
162
+ try: () => execFileAsync("xurl", ["version"]),
163
+ catch: normalizeError,
164
+ }).pipe(
165
+ Effect.as(true),
166
+ Effect.catchAll(() => Effect.succeed(false)),
167
+ );
158
168
  }
159
169
 
160
170
  function isUnauthenticatedXurlStatus(status: string) {
@@ -163,164 +173,233 @@ function isUnauthenticatedXurlStatus(status: string) {
163
173
  );
164
174
  }
165
175
 
166
- export async function getTransportStatus(): Promise<TransportStatus> {
167
- const now = Date.now();
168
- if (transportStatusCache?.value && transportStatusCache.expiresAt > now) {
169
- return transportStatusCache.value;
170
- }
171
-
172
- if (transportStatusCache?.pending) {
173
- return transportStatusCache.pending;
174
- }
175
-
176
- const pending: Promise<TransportStatus> = (async () => {
177
- const installed = await hasXurl();
176
+ function readTransportStatusEffect(): Effect.Effect<TransportStatus, never> {
177
+ return Effect.gen(function* () {
178
+ const installed = yield* hasXurlEffect();
178
179
  if (!installed) {
179
180
  return {
180
181
  installed: false,
181
- availableTransport: "local",
182
+ availableTransport: "local" as const,
182
183
  statusText: "xurl not installed. local mode active.",
183
184
  };
184
185
  }
185
186
 
186
- try {
187
- const { stdout } = await execFileAsync("xurl", ["auth", "status"]);
188
- const rawStatus = stdout.trim();
187
+ return yield* Effect.tryPromise({
188
+ try: () => execFileAsync("xurl", ["auth", "status"]),
189
+ catch: (cause) => cause,
190
+ }).pipe(
191
+ Effect.map(({ stdout }) => {
192
+ const rawStatus = stdout.trim();
193
+
194
+ if (isUnauthenticatedXurlStatus(rawStatus)) {
195
+ return {
196
+ installed: true,
197
+ availableTransport: "local" as const,
198
+ statusText:
199
+ "xurl installed but not authenticated. local (bird) mode active.",
200
+ rawStatus,
201
+ };
202
+ }
189
203
 
190
- if (isUnauthenticatedXurlStatus(rawStatus)) {
191
204
  return {
192
205
  installed: true,
193
- availableTransport: "local",
194
- statusText:
195
- "xurl installed but not authenticated. local (bird) mode active.",
206
+ availableTransport: "xurl" as const,
207
+ statusText: "xurl available",
196
208
  rawStatus,
197
209
  };
198
- }
210
+ }),
211
+ Effect.catchAll((error) =>
212
+ Effect.succeed({
213
+ installed: true,
214
+ availableTransport: "local" as const,
215
+ statusText: `xurl detected but auth unavailable: ${
216
+ error instanceof Error ? error.message : "unknown error"
217
+ }`,
218
+ }),
219
+ ),
220
+ );
221
+ });
222
+ }
199
223
 
200
- return {
201
- installed: true,
202
- availableTransport: "xurl",
203
- statusText: "xurl available",
204
- rawStatus,
205
- };
206
- } catch (error) {
207
- return {
208
- installed: true,
209
- availableTransport: "local",
210
- statusText: `xurl detected but auth unavailable: ${
211
- error instanceof Error ? error.message : "unknown error"
212
- }`,
213
- };
224
+ export function getTransportStatusEffect() {
225
+ return Effect.gen(function* () {
226
+ const now = Date.now();
227
+ if (transportStatusCache?.value && transportStatusCache.expiresAt > now) {
228
+ return transportStatusCache.value;
214
229
  }
215
- })();
216
230
 
217
- transportStatusCache = {
218
- expiresAt: 0,
219
- pending,
220
- };
231
+ if (transportStatusCache?.pending) {
232
+ const status = yield* Effect.tryPromise({
233
+ try: () => transportStatusCache?.pending ?? Promise.resolve(undefined),
234
+ catch: normalizeError,
235
+ });
236
+ if (status) return status;
237
+ }
221
238
 
222
- try {
223
- const status = await pending;
239
+ const pending = runEffectPromise(readTransportStatusEffect());
240
+
241
+ transportStatusCache = {
242
+ expiresAt: 0,
243
+ pending,
244
+ };
245
+
246
+ const status = yield* Effect.tryPromise({
247
+ try: () => pending,
248
+ catch: normalizeError,
249
+ }).pipe(
250
+ Effect.catchAll((error) =>
251
+ Effect.sync(() => {
252
+ transportStatusCache = undefined;
253
+ }).pipe(Effect.flatMap(() => Effect.fail(error))),
254
+ ),
255
+ );
224
256
  transportStatusCache = {
225
257
  expiresAt: Date.now() + TRANSPORT_STATUS_TTL_MS,
226
258
  value: status,
227
259
  };
228
260
  return status;
229
- } catch (error) {
230
- transportStatusCache = undefined;
231
- throw error;
232
- }
261
+ });
233
262
  }
234
263
 
235
- async function runShortcut(
264
+ export function getTransportStatus(): Promise<TransportStatus> {
265
+ return runEffectPromise(getTransportStatusEffect());
266
+ }
267
+
268
+ function runShortcutEffect(args: string[]) {
269
+ return Effect.gen(function* () {
270
+ if (liveWritesDisabled()) {
271
+ if (e2eFakeLiveWritesEnabled()) {
272
+ return { ok: true, output: "e2e fake live write" };
273
+ }
274
+ return { ok: false, output: "live writes disabled" };
275
+ }
276
+
277
+ return yield* Effect.tryPromise({
278
+ try: () => execFileAsync("xurl", args),
279
+ catch: normalizeError,
280
+ }).pipe(
281
+ Effect.map(({ stdout, stderr }) => ({
282
+ ok: true,
283
+ output: stdout || stderr,
284
+ })),
285
+ Effect.catchAll((error) =>
286
+ Effect.succeed({
287
+ ok: false,
288
+ output: formatExecError(error, "xurl execution failed"),
289
+ }),
290
+ ),
291
+ );
292
+ });
293
+ }
294
+
295
+ function execXurlJsonEffect(
236
296
  args: string[],
237
- ): Promise<{ ok: boolean; output: string }> {
238
- if (liveWritesDisabled()) {
239
- return { ok: true, output: "live writes disabled" };
240
- }
297
+ timeoutMs?: number,
298
+ ): Effect.Effect<{ stdout: string; stderr: string }, Error> {
299
+ return Effect.tryPromise({
300
+ try: () => {
301
+ const controller =
302
+ typeof timeoutMs === "number" &&
303
+ Number.isFinite(timeoutMs) &&
304
+ timeoutMs > 0
305
+ ? new AbortController()
306
+ : undefined;
307
+ const timeout = controller
308
+ ? setTimeout(() => controller.abort(), timeoutMs)
309
+ : undefined;
310
+ const result = controller
311
+ ? execFileAsync("xurl", args, { signal: controller.signal })
312
+ : execFileAsync("xurl", args);
313
+ return result.finally(() => {
314
+ if (timeout) {
315
+ clearTimeout(timeout);
316
+ }
317
+ });
318
+ },
319
+ catch: normalizeError,
320
+ });
321
+ }
241
322
 
242
- try {
243
- const { stdout, stderr } = await execFileAsync("xurl", args);
244
- return { ok: true, output: stdout || stderr };
245
- } catch (error) {
246
- return {
247
- ok: false,
248
- output: formatExecError(error, "xurl execution failed"),
249
- };
250
- }
323
+ function parseJsonPayloadEffect(
324
+ stdout: string,
325
+ args: string[],
326
+ ): Effect.Effect<Record<string, unknown>, Error> {
327
+ return Effect.try({
328
+ try: () => JSON.parse(stdout) as Record<string, unknown>,
329
+ catch: (error) => formatXurlCommandError(error, args),
330
+ });
251
331
  }
252
332
 
253
- async function runJsonCommand(
333
+ function runJsonCommandEffect(
254
334
  args: string[],
255
335
  options: JsonCommandOptions = {},
256
336
  attempt = 0,
257
- ) {
258
- const deadlineMs =
259
- options.deadlineMs ??
260
- (typeof options.timeoutMs === "number" &&
261
- Number.isFinite(options.timeoutMs) &&
262
- options.timeoutMs > 0
263
- ? Date.now() + options.timeoutMs
264
- : undefined);
265
- const timeoutMs = deadlineMs
266
- ? Math.max(0, deadlineMs - Date.now())
267
- : undefined;
268
- const controller =
269
- typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
270
- ? new AbortController()
271
- : undefined;
272
- const timeout = controller
273
- ? setTimeout(() => controller.abort(), timeoutMs)
274
- : undefined;
275
-
276
- try {
277
- const { stdout } = controller
278
- ? await execFileAsync("xurl", args, { signal: controller.signal })
279
- : await execFileAsync("xurl", args);
280
- return JSON.parse(stdout) as Record<string, unknown>;
281
- } catch (error) {
282
- const retryDelayMs = getRetryDelayMs(error, attempt);
283
- if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
284
- throw formatXurlCommandError(error, args);
285
- }
286
- const remainingMs = deadlineMs
337
+ ): Effect.Effect<Record<string, unknown>, Error> {
338
+ return Effect.gen(function* () {
339
+ const deadlineMs =
340
+ options.deadlineMs ??
341
+ (typeof options.timeoutMs === "number" &&
342
+ Number.isFinite(options.timeoutMs) &&
343
+ options.timeoutMs > 0
344
+ ? Date.now() + options.timeoutMs
345
+ : undefined);
346
+ const timeoutMs = deadlineMs
287
347
  ? Math.max(0, deadlineMs - Date.now())
288
348
  : undefined;
289
- if (remainingMs !== undefined && retryDelayMs >= remainingMs) {
290
- throw formatXurlCommandError(error, args);
291
- }
292
-
293
- await sleep(retryDelayMs);
294
- return runJsonCommand(args, { ...options, deadlineMs }, attempt + 1);
295
- } finally {
296
- if (timeout) {
297
- clearTimeout(timeout);
298
- }
299
- }
349
+ return yield* execXurlJsonEffect(args, timeoutMs).pipe(
350
+ Effect.flatMap(({ stdout }) => parseJsonPayloadEffect(stdout, args)),
351
+ Effect.catchAll((error) => {
352
+ const retryDelayMs = getRetryDelayMs(error, attempt);
353
+ if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
354
+ return Effect.fail(formatXurlCommandError(error, args));
355
+ }
356
+ const remainingMs = deadlineMs
357
+ ? Math.max(0, deadlineMs - Date.now())
358
+ : undefined;
359
+ if (remainingMs !== undefined && retryDelayMs >= remainingMs) {
360
+ return Effect.fail(formatXurlCommandError(error, args));
361
+ }
362
+
363
+ return Effect.sleep(retryDelayMs).pipe(
364
+ Effect.flatMap(() =>
365
+ runJsonCommandEffect(args, { ...options, deadlineMs }, attempt + 1),
366
+ ),
367
+ );
368
+ }),
369
+ );
370
+ });
300
371
  }
301
372
 
302
- async function runMutationCommand(args: string[]) {
303
- if (liveWritesDisabled()) {
304
- return { ok: true, output: "live writes disabled" };
305
- }
373
+ function runMutationCommandEffect(args: string[]) {
374
+ return Effect.gen(function* () {
375
+ if (liveWritesDisabled()) {
376
+ if (e2eFakeLiveWritesEnabled()) {
377
+ return { ok: true, output: "e2e fake live write" };
378
+ }
379
+ return { ok: false, output: "live writes disabled" };
380
+ }
306
381
 
307
- try {
308
- const { stdout, stderr } = await execFileAsync("xurl", args);
309
- return {
310
- ok: true,
311
- output: stdout || stderr || "ok",
312
- };
313
- } catch (error) {
314
- return {
315
- ok: false,
316
- output: formatExecError(error, "xurl execution failed"),
317
- };
318
- }
382
+ return yield* Effect.tryPromise({
383
+ try: () => execFileAsync("xurl", args),
384
+ catch: normalizeError,
385
+ }).pipe(
386
+ Effect.map(({ stdout, stderr }) => ({
387
+ ok: true,
388
+ output: stdout || stderr || "ok",
389
+ })),
390
+ Effect.catchAll((error) =>
391
+ Effect.succeed({
392
+ ok: false,
393
+ output: formatExecError(error, "xurl execution failed"),
394
+ }),
395
+ ),
396
+ );
397
+ });
319
398
  }
320
399
 
321
- export async function lookupUsersByIds(ids: string[]) {
400
+ export function lookupUsersByIdsEffect(ids: string[]) {
322
401
  if (ids.length === 0) {
323
- return [];
402
+ return Effect.succeed([]);
324
403
  }
325
404
 
326
405
  const query = new URLSearchParams({
@@ -328,14 +407,20 @@ export async function lookupUsersByIds(ids: string[]) {
328
407
  "user.fields":
329
408
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
330
409
  });
331
- const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
332
- const data = payload.data;
333
- return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
410
+ return runJsonCommandEffect([`/2/users?${query.toString()}`]).pipe(
411
+ Effect.map((payload) =>
412
+ Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
413
+ ),
414
+ );
415
+ }
416
+
417
+ export function lookupUsersByIds(ids: string[]) {
418
+ return runEffectPromise(lookupUsersByIdsEffect(ids));
334
419
  }
335
420
 
336
- export async function lookupUsersByHandles(handles: string[]) {
421
+ export function lookupUsersByHandlesEffect(handles: string[]) {
337
422
  if (handles.length === 0) {
338
- return [];
423
+ return Effect.succeed([]);
339
424
  }
340
425
 
341
426
  const query = new URLSearchParams({
@@ -343,52 +428,124 @@ export async function lookupUsersByHandles(handles: string[]) {
343
428
  "user.fields":
344
429
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
345
430
  });
346
- const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
431
+ return runJsonCommandEffect([`/2/users/by?${query.toString()}`]).pipe(
432
+ Effect.map((payload) =>
433
+ Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
434
+ ),
435
+ );
436
+ }
437
+
438
+ export function lookupUsersByHandles(handles: string[]) {
439
+ return runEffectPromise(lookupUsersByHandlesEffect(handles));
440
+ }
441
+
442
+ function authenticatedUserFromPayload(payload: Record<string, unknown>) {
347
443
  const data = payload.data;
348
- return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
444
+ return data && typeof data === "object"
445
+ ? (data as Record<string, unknown>)
446
+ : null;
349
447
  }
350
448
 
351
- export async function lookupAuthenticatedUser() {
352
- const now = Date.now();
353
- if (
354
- authenticatedUserCache &&
355
- "value" in authenticatedUserCache &&
356
- authenticatedUserCache.expiresAt > now
357
- ) {
358
- return authenticatedUserCache.value ?? null;
359
- }
449
+ export function lookupAuthenticatedUserFreshEffect() {
450
+ return runJsonCommandEffect(["whoami"]).pipe(
451
+ Effect.map(authenticatedUserFromPayload),
452
+ );
453
+ }
360
454
 
361
- if (authenticatedUserCache?.pending) {
362
- return authenticatedUserCache.pending;
363
- }
455
+ function oauth2UsernameArgs(username?: string) {
456
+ const normalized = username?.trim().replace(/^@/, "");
457
+ return normalized ? ["--username", normalized] : [];
458
+ }
364
459
 
365
- const pending = (async () => {
366
- const payload = await runJsonCommand(["whoami"]);
367
- const data = payload.data;
368
- return data && typeof data === "object"
369
- ? (data as Record<string, unknown>)
370
- : null;
371
- })();
372
-
373
- authenticatedUserCache = {
374
- expiresAt: 0,
375
- pending,
376
- };
460
+ export function lookupAuthenticatedOAuth2UserEffect(username?: string) {
461
+ return runJsonCommandEffect([
462
+ "--auth",
463
+ "oauth2",
464
+ ...oauth2UsernameArgs(username),
465
+ "whoami",
466
+ ]).pipe(Effect.map(authenticatedUserFromPayload));
467
+ }
377
468
 
378
- try {
379
- const value = await pending;
469
+ export function lookupAuthenticatedUserEffect() {
470
+ return Effect.gen(function* () {
471
+ const now = Date.now();
472
+ if (
473
+ authenticatedUserCache &&
474
+ "value" in authenticatedUserCache &&
475
+ authenticatedUserCache.expiresAt > now
476
+ ) {
477
+ return authenticatedUserCache.value ?? null;
478
+ }
479
+
480
+ if (authenticatedUserCache?.pending) {
481
+ return yield* Effect.tryPromise({
482
+ try: () => authenticatedUserCache?.pending ?? Promise.resolve(null),
483
+ catch: normalizeError,
484
+ });
485
+ }
486
+
487
+ const pending = runEffectPromise(lookupAuthenticatedUserFreshEffect());
488
+
489
+ authenticatedUserCache = {
490
+ expiresAt: 0,
491
+ pending,
492
+ };
493
+
494
+ const value = yield* Effect.tryPromise({
495
+ try: () => pending,
496
+ catch: normalizeError,
497
+ }).pipe(
498
+ Effect.catchAll((error) =>
499
+ Effect.sync(() => {
500
+ authenticatedUserCache = undefined;
501
+ }).pipe(Effect.flatMap(() => Effect.fail(error))),
502
+ ),
503
+ );
380
504
  authenticatedUserCache = {
381
505
  expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
382
506
  value,
383
507
  };
384
508
  return value;
385
- } catch (error) {
386
- authenticatedUserCache = undefined;
387
- throw error;
388
- }
509
+ });
510
+ }
511
+
512
+ export function lookupAuthenticatedUser() {
513
+ return runEffectPromise(lookupAuthenticatedUserEffect());
514
+ }
515
+
516
+ export function lookupAuthenticatedUserFresh() {
517
+ return runEffectPromise(lookupAuthenticatedUserFreshEffect());
389
518
  }
390
519
 
391
- export async function listMentionsViaXurl({
520
+ function resolveUserIdEffect({
521
+ username,
522
+ userId,
523
+ }: {
524
+ username?: string;
525
+ userId?: string;
526
+ }) {
527
+ return Effect.gen(function* () {
528
+ if (userId) return userId;
529
+ if (username) {
530
+ const [user] = yield* lookupUsersByHandlesEffect([username]);
531
+ if (!user?.id) {
532
+ return yield* Effect.fail(
533
+ new Error(`Could not resolve Twitter user id for @${username}`),
534
+ );
535
+ }
536
+ return String(user.id);
537
+ }
538
+ const user = yield* lookupAuthenticatedUserEffect();
539
+ if (!user?.id) {
540
+ return yield* Effect.fail(
541
+ new Error("Could not resolve authenticated Twitter user id"),
542
+ );
543
+ }
544
+ return String(user.id);
545
+ });
546
+ }
547
+
548
+ export function listMentionsViaXurlEffect({
392
549
  maxResults,
393
550
  username,
394
551
  userId,
@@ -402,45 +559,48 @@ export async function listMentionsViaXurl({
402
559
  paginationToken?: string;
403
560
  sinceId?: string;
404
561
  startTime?: string;
405
- }): Promise<XurlMentionsResponse> {
406
- let resolvedUserId = userId;
407
- if (!resolvedUserId) {
408
- if (username) {
409
- const [user] = await lookupUsersByHandles([username]);
410
- if (!user?.id) {
411
- throw new Error(`Could not resolve Twitter user id for @${username}`);
412
- }
413
- resolvedUserId = String(user.id);
414
- } else {
415
- const user = await lookupAuthenticatedUser();
416
- if (!user?.id) {
417
- throw new Error("Could not resolve authenticated Twitter user id");
418
- }
419
- resolvedUserId = String(user.id);
562
+ }): Effect.Effect<XurlMentionsResponse, Error> {
563
+ return Effect.gen(function* () {
564
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
565
+ const query = new URLSearchParams({
566
+ max_results: String(maxResults),
567
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
568
+ "tweet.fields": "created_at,conversation_id,entities,public_metrics",
569
+ "media.fields": MEDIA_FIELDS,
570
+ "user.fields":
571
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
572
+ });
573
+ if (paginationToken) {
574
+ query.set("pagination_token", paginationToken);
575
+ }
576
+ if (sinceId) {
577
+ query.set("since_id", sinceId);
578
+ }
579
+ if (startTime) {
580
+ query.set("start_time", startTime);
420
581
  }
421
- }
422
582
 
423
- const query = new URLSearchParams({
424
- max_results: String(maxResults),
425
- expansions: AUTHOR_MEDIA_EXPANSIONS,
426
- "tweet.fields": "created_at,conversation_id,entities,public_metrics",
427
- "media.fields": MEDIA_FIELDS,
428
- "user.fields":
429
- "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
583
+ const payload = yield* runJsonCommandEffect([
584
+ `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
585
+ ]);
586
+ return toXurlMentionsResponse(payload);
430
587
  });
431
- if (paginationToken) {
432
- query.set("pagination_token", paginationToken);
433
- }
434
- if (sinceId) {
435
- query.set("since_id", sinceId);
436
- }
437
- if (startTime) {
438
- query.set("start_time", startTime);
439
- }
588
+ }
440
589
 
441
- const payload = await runJsonCommand([
442
- `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
443
- ]);
590
+ export function listMentionsViaXurl(options: {
591
+ maxResults: number;
592
+ username?: string;
593
+ userId?: string;
594
+ paginationToken?: string;
595
+ sinceId?: string;
596
+ startTime?: string;
597
+ }): Promise<XurlMentionsResponse> {
598
+ return runEffectPromise(listMentionsViaXurlEffect(options));
599
+ }
600
+
601
+ function toXurlMentionsResponse(
602
+ payload: Record<string, unknown>,
603
+ ): XurlMentionsResponse {
444
604
  return {
445
605
  data: Array.isArray(payload.data)
446
606
  ? (payload.data as XurlMentionsResponse["data"])
@@ -456,7 +616,7 @@ export async function listMentionsViaXurl({
456
616
  };
457
617
  }
458
618
 
459
- async function listTimelineCollectionViaXurl({
619
+ function listTimelineCollectionViaXurlEffect({
460
620
  collection,
461
621
  maxResults,
462
622
  username,
@@ -470,143 +630,182 @@ async function listTimelineCollectionViaXurl({
470
630
  userId?: string;
471
631
  isPaginatedWalk?: boolean;
472
632
  paginationToken?: string;
473
- }): Promise<XurlMentionsResponse> {
474
- let resolvedUserId = userId;
475
- if (!resolvedUserId) {
476
- if (username) {
477
- const [user] = await lookupUsersByHandles([username]);
478
- if (!user?.id) {
479
- throw new Error(`Could not resolve Twitter user id for @${username}`);
480
- }
481
- resolvedUserId = String(user.id);
482
- } else {
483
- const user = await lookupAuthenticatedUser();
484
- if (!user?.id) {
485
- throw new Error("Could not resolve authenticated Twitter user id");
486
- }
487
- resolvedUserId = String(user.id);
633
+ }): Effect.Effect<XurlMentionsResponse, Error> {
634
+ return Effect.gen(function* () {
635
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
636
+ const requestMaxResults = capTimelineCollectionMaxResults(
637
+ collection,
638
+ maxResults,
639
+ isPaginatedWalk,
640
+ );
641
+ const query = new URLSearchParams({
642
+ max_results: String(requestMaxResults),
643
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
644
+ "tweet.fields":
645
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
646
+ "media.fields": MEDIA_FIELDS,
647
+ "user.fields":
648
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
649
+ });
650
+ if (paginationToken) {
651
+ query.set("pagination_token", paginationToken);
488
652
  }
489
- }
490
653
 
491
- const requestMaxResults = capTimelineCollectionMaxResults(
492
- collection,
493
- maxResults,
494
- isPaginatedWalk,
495
- );
496
- const query = new URLSearchParams({
497
- max_results: String(requestMaxResults),
498
- expansions: AUTHOR_MEDIA_EXPANSIONS,
499
- "tweet.fields":
500
- "created_at,conversation_id,entities,public_metrics,referenced_tweets",
501
- "media.fields": MEDIA_FIELDS,
502
- "user.fields":
503
- "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
654
+ const payload = yield* runJsonCommandEffect([
655
+ "--auth",
656
+ "oauth2",
657
+ `/2/users/${resolvedUserId}/${collection}?${query.toString()}`,
658
+ ]);
659
+ return toXurlMentionsResponse(payload);
504
660
  });
505
- if (paginationToken) {
506
- query.set("pagination_token", paginationToken);
507
- }
508
-
509
- const payload = await runJsonCommand([
510
- "--auth",
511
- "oauth2",
512
- `/2/users/${resolvedUserId}/${collection}?${query.toString()}`,
513
- ]);
514
- return {
515
- data: Array.isArray(payload.data)
516
- ? (payload.data as XurlMentionsResponse["data"])
517
- : [],
518
- includes:
519
- payload.includes && typeof payload.includes === "object"
520
- ? (payload.includes as XurlMentionsResponse["includes"])
521
- : undefined,
522
- meta:
523
- payload.meta && typeof payload.meta === "object"
524
- ? (payload.meta as XurlMentionsResponse["meta"])
525
- : undefined,
526
- };
527
661
  }
528
662
 
529
- export async function listLikedTweetsViaXurl(options: {
663
+ export function listLikedTweetsViaXurlEffect(options: {
530
664
  maxResults: number;
531
665
  username?: string;
532
666
  userId?: string;
533
667
  paginationToken?: string;
534
- }): Promise<XurlMentionsResponse> {
535
- return listTimelineCollectionViaXurl({
668
+ }) {
669
+ return listTimelineCollectionViaXurlEffect({
536
670
  ...options,
537
671
  collection: "liked_tweets",
538
672
  });
539
673
  }
540
674
 
541
- export async function listBookmarkedTweetsViaXurl(options: {
675
+ export function listLikedTweetsViaXurl(options: {
542
676
  maxResults: number;
543
677
  username?: string;
544
678
  userId?: string;
545
- isPaginatedWalk?: boolean;
546
679
  paginationToken?: string;
547
680
  }): Promise<XurlMentionsResponse> {
548
- return listTimelineCollectionViaXurl({
681
+ return runEffectPromise(listLikedTweetsViaXurlEffect(options));
682
+ }
683
+
684
+ export function listBookmarkedTweetsViaXurlEffect(options: {
685
+ maxResults: number;
686
+ username?: string;
687
+ userId?: string;
688
+ isPaginatedWalk?: boolean;
689
+ paginationToken?: string;
690
+ }) {
691
+ return listTimelineCollectionViaXurlEffect({
549
692
  ...options,
550
693
  collection: "bookmarks",
551
694
  });
552
695
  }
553
696
 
554
- export async function listFollowUsersViaXurl({
555
- direction,
697
+ export function listBookmarkedTweetsViaXurl(options: {
698
+ maxResults: number;
699
+ username?: string;
700
+ userId?: string;
701
+ isPaginatedWalk?: boolean;
702
+ paginationToken?: string;
703
+ }): Promise<XurlMentionsResponse> {
704
+ return runEffectPromise(listBookmarkedTweetsViaXurlEffect(options));
705
+ }
706
+
707
+ export function listDirectMessageEventsViaXurlEffect({
556
708
  maxResults,
557
709
  username,
558
- userId,
559
710
  paginationToken,
560
711
  }: {
561
- direction: FollowDirection;
562
712
  maxResults: number;
563
713
  username?: string;
564
- userId?: string;
565
714
  paginationToken?: string;
566
- }): Promise<XurlFollowUsersResponse> {
567
- let resolvedUserId = userId;
568
- if (!resolvedUserId) {
569
- if (username) {
570
- const [user] = await lookupUsersByHandles([username]);
571
- if (!user?.id) {
572
- throw new Error(`Could not resolve Twitter user id for @${username}`);
573
- }
574
- resolvedUserId = String(user.id);
575
- } else {
576
- const user = await lookupAuthenticatedUser();
577
- if (!user?.id) {
578
- throw new Error("Could not resolve authenticated Twitter user id");
579
- }
580
- resolvedUserId = String(user.id);
581
- }
582
- }
583
-
715
+ }): Effect.Effect<XurlDmEventsResponse, Error> {
584
716
  const query = new URLSearchParams({
585
717
  max_results: String(maxResults),
586
- "user.fields":
587
- "id,username,name,description,verified,protected,public_metrics,profile_image_url,created_at",
718
+ event_types: "MessageCreate",
719
+ "dm_event.fields": DM_EVENT_FIELDS,
720
+ expansions: "sender_id,participant_ids",
721
+ "user.fields": RICH_USER_FIELDS,
588
722
  });
589
723
  if (paginationToken) {
590
724
  query.set("pagination_token", paginationToken);
591
725
  }
592
726
 
593
- const payload = await runJsonCommand([
727
+ return runJsonCommandEffect([
594
728
  "--auth",
595
729
  "oauth2",
596
- `/2/users/${resolvedUserId}/${direction}?${query.toString()}`,
597
- ]);
598
- return {
599
- data: Array.isArray(payload.data)
600
- ? (payload.data as XurlMentionUser[])
601
- : [],
602
- meta:
603
- payload.meta && typeof payload.meta === "object"
604
- ? (payload.meta as Record<string, unknown>)
605
- : undefined,
606
- };
730
+ ...oauth2UsernameArgs(username),
731
+ `/2/dm_events?${query.toString()}`,
732
+ ]).pipe(
733
+ Effect.map((payload) => ({
734
+ data: Array.isArray(payload.data)
735
+ ? (payload.data as XurlDmEventsResponse["data"])
736
+ : [],
737
+ includes:
738
+ payload.includes && typeof payload.includes === "object"
739
+ ? (payload.includes as XurlDmEventsResponse["includes"])
740
+ : undefined,
741
+ meta:
742
+ payload.meta && typeof payload.meta === "object"
743
+ ? (payload.meta as Record<string, unknown>)
744
+ : undefined,
745
+ })),
746
+ );
747
+ }
748
+
749
+ export function listDirectMessageEventsViaXurl(options: {
750
+ maxResults: number;
751
+ username?: string;
752
+ paginationToken?: string;
753
+ }): Promise<XurlDmEventsResponse> {
754
+ return runEffectPromise(listDirectMessageEventsViaXurlEffect(options));
755
+ }
756
+
757
+ export function listFollowUsersViaXurlEffect({
758
+ direction,
759
+ maxResults,
760
+ username,
761
+ userId,
762
+ paginationToken,
763
+ }: {
764
+ direction: FollowDirection;
765
+ maxResults: number;
766
+ username?: string;
767
+ userId?: string;
768
+ paginationToken?: string;
769
+ }): Effect.Effect<XurlFollowUsersResponse, Error> {
770
+ return Effect.gen(function* () {
771
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
772
+ const query = new URLSearchParams({
773
+ max_results: String(maxResults),
774
+ "user.fields":
775
+ "id,username,name,description,verified,protected,public_metrics,profile_image_url,created_at",
776
+ });
777
+ if (paginationToken) {
778
+ query.set("pagination_token", paginationToken);
779
+ }
780
+
781
+ const payload = yield* runJsonCommandEffect([
782
+ "--auth",
783
+ "oauth2",
784
+ `/2/users/${resolvedUserId}/${direction}?${query.toString()}`,
785
+ ]);
786
+ return {
787
+ data: Array.isArray(payload.data)
788
+ ? (payload.data as XurlMentionUser[])
789
+ : [],
790
+ meta:
791
+ payload.meta && typeof payload.meta === "object"
792
+ ? (payload.meta as Record<string, unknown>)
793
+ : undefined,
794
+ };
795
+ });
607
796
  }
608
797
 
609
- export async function listBlockedUsers(
798
+ export function listFollowUsersViaXurl(options: {
799
+ direction: FollowDirection;
800
+ maxResults: number;
801
+ username?: string;
802
+ userId?: string;
803
+ paginationToken?: string;
804
+ }): Promise<XurlFollowUsersResponse> {
805
+ return runEffectPromise(listFollowUsersViaXurlEffect(options));
806
+ }
807
+
808
+ export function listBlockedUsersEffect(
610
809
  userId: string,
611
810
  paginationToken?: string,
612
811
  ) {
@@ -619,25 +818,30 @@ export async function listBlockedUsers(
619
818
  query.set("pagination_token", paginationToken);
620
819
  }
621
820
 
622
- const payload = await runJsonCommand([
623
- `/2/users/${userId}/blocking?${query}`,
624
- ]);
625
- const data = Array.isArray(payload.data)
626
- ? (payload.data as XurlMentionUser[])
627
- : [];
628
- const meta =
629
- payload.meta && typeof payload.meta === "object"
630
- ? (payload.meta as Record<string, unknown>)
631
- : null;
821
+ return runJsonCommandEffect([`/2/users/${userId}/blocking?${query}`]).pipe(
822
+ Effect.map((payload) => {
823
+ const data = Array.isArray(payload.data)
824
+ ? (payload.data as XurlMentionUser[])
825
+ : [];
826
+ const meta =
827
+ payload.meta && typeof payload.meta === "object"
828
+ ? (payload.meta as Record<string, unknown>)
829
+ : null;
632
830
 
633
- return {
634
- items: data,
635
- nextToken:
636
- typeof meta?.next_token === "string" ? String(meta.next_token) : null,
637
- };
831
+ return {
832
+ items: data,
833
+ nextToken:
834
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
835
+ };
836
+ }),
837
+ );
838
+ }
839
+
840
+ export function listBlockedUsers(userId: string, paginationToken?: string) {
841
+ return runEffectPromise(listBlockedUsersEffect(userId, paginationToken));
638
842
  }
639
843
 
640
- export async function listUserTweets(
844
+ export function listUserTweetsEffect(
641
845
  userId: string,
642
846
  {
643
847
  maxResults,
@@ -662,7 +866,7 @@ export async function listUserTweets(
662
866
  mediaFields?: string[];
663
867
  auth?: "oauth2";
664
868
  },
665
- ): Promise<XurlUserTweetsResponse> {
869
+ ): Effect.Effect<XurlUserTweetsResponse, Error> {
666
870
  const query = new URLSearchParams({
667
871
  max_results: String(maxResults),
668
872
  expansions: MEDIA_EXPANSION,
@@ -694,34 +898,73 @@ export async function listUserTweets(
694
898
  }
695
899
 
696
900
  const endpoint = `/2/users/${userId}/tweets?${query}`;
697
- const payload = await runJsonCommand(
901
+ return runJsonCommandEffect(
698
902
  auth === "oauth2" ? ["--auth", "oauth2", endpoint] : [endpoint],
903
+ ).pipe(
904
+ Effect.map((payload) => {
905
+ const data = Array.isArray(payload.data)
906
+ ? (payload.data as XurlUserTweet[])
907
+ : [];
908
+ const meta =
909
+ payload.meta && typeof payload.meta === "object"
910
+ ? (payload.meta as Record<string, unknown>)
911
+ : null;
912
+ const includes =
913
+ payload.includes && typeof payload.includes === "object"
914
+ ? (payload.includes as XurlUserTweetsResponse["includes"])
915
+ : undefined;
916
+
917
+ return {
918
+ items: data,
919
+ nextToken:
920
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
921
+ ...(includes ? { includes } : {}),
922
+ };
923
+ }),
699
924
  );
700
- const data = Array.isArray(payload.data)
701
- ? (payload.data as XurlUserTweet[])
702
- : [];
703
- const meta =
704
- payload.meta && typeof payload.meta === "object"
705
- ? (payload.meta as Record<string, unknown>)
706
- : null;
707
- const includes =
708
- payload.includes && typeof payload.includes === "object"
709
- ? (payload.includes as XurlUserTweetsResponse["includes"])
710
- : undefined;
925
+ }
926
+
927
+ export function listUserTweets(
928
+ userId: string,
929
+ options: {
930
+ maxResults: number;
931
+ paginationToken?: string;
932
+ excludeRetweets?: boolean;
933
+ sinceId?: string;
934
+ untilId?: string;
935
+ tweetFields?: string[];
936
+ expansions?: string[];
937
+ userFields?: string[];
938
+ mediaFields?: string[];
939
+ auth?: "oauth2";
940
+ },
941
+ ): Promise<XurlUserTweetsResponse> {
942
+ return runEffectPromise(listUserTweetsEffect(userId, options));
943
+ }
711
944
 
945
+ function toXurlTweetsResponse(
946
+ payload: Record<string, unknown>,
947
+ ): XurlTweetsResponse {
712
948
  return {
713
- items: data,
714
- nextToken:
715
- typeof meta?.next_token === "string" ? String(meta.next_token) : null,
716
- ...(includes ? { includes } : {}),
949
+ data: Array.isArray(payload.data)
950
+ ? (payload.data as XurlTweetsResponse["data"])
951
+ : [],
952
+ includes:
953
+ payload.includes && typeof payload.includes === "object"
954
+ ? (payload.includes as XurlTweetsResponse["includes"])
955
+ : undefined,
956
+ meta:
957
+ payload.meta && typeof payload.meta === "object"
958
+ ? (payload.meta as XurlTweetsResponse["meta"])
959
+ : undefined,
717
960
  };
718
961
  }
719
962
 
720
- export async function lookupTweetsByIds(
963
+ export function lookupTweetsByIdsEffect(
721
964
  ids: string[],
722
- ): Promise<XurlTweetsResponse> {
965
+ ): Effect.Effect<XurlTweetsResponse, Error> {
723
966
  if (ids.length === 0) {
724
- return { data: [] };
967
+ return Effect.succeed({ data: [] });
725
968
  }
726
969
 
727
970
  const query = new URLSearchParams({
@@ -734,23 +977,16 @@ export async function lookupTweetsByIds(
734
977
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
735
978
  });
736
979
 
737
- const payload = await runJsonCommand([`/2/tweets?${query.toString()}`]);
738
- return {
739
- data: Array.isArray(payload.data)
740
- ? (payload.data as XurlTweetsResponse["data"])
741
- : [],
742
- includes:
743
- payload.includes && typeof payload.includes === "object"
744
- ? (payload.includes as XurlTweetsResponse["includes"])
745
- : undefined,
746
- meta:
747
- payload.meta && typeof payload.meta === "object"
748
- ? (payload.meta as XurlTweetsResponse["meta"])
749
- : undefined,
750
- };
980
+ return runJsonCommandEffect([`/2/tweets?${query.toString()}`]).pipe(
981
+ Effect.map(toXurlTweetsResponse),
982
+ );
751
983
  }
752
984
 
753
- export async function searchRecentByConversationId(
985
+ export function lookupTweetsByIds(ids: string[]): Promise<XurlTweetsResponse> {
986
+ return runEffectPromise(lookupTweetsByIdsEffect(ids));
987
+ }
988
+
989
+ export function searchRecentByConversationIdEffect(
754
990
  conversationId: string,
755
991
  {
756
992
  maxResults,
@@ -761,7 +997,7 @@ export async function searchRecentByConversationId(
761
997
  paginationToken?: string;
762
998
  timeoutMs?: number;
763
999
  },
764
- ): Promise<XurlTweetsResponse> {
1000
+ ): Effect.Effect<XurlTweetsResponse, Error> {
765
1001
  const query = new URLSearchParams({
766
1002
  query: `conversation_id:${conversationId}`,
767
1003
  max_results: String(maxResults),
@@ -774,29 +1010,28 @@ export async function searchRecentByConversationId(
774
1010
  query.set("pagination_token", paginationToken);
775
1011
  }
776
1012
 
777
- const payload = await runJsonCommand(
778
- [`/2/tweets/search/recent?${query.toString()}`],
779
- { timeoutMs },
1013
+ return runJsonCommandEffect([`/2/tweets/search/recent?${query.toString()}`], {
1014
+ timeoutMs,
1015
+ }).pipe(Effect.map(toXurlTweetsResponse));
1016
+ }
1017
+
1018
+ export function searchRecentByConversationId(
1019
+ conversationId: string,
1020
+ options: {
1021
+ maxResults: number;
1022
+ paginationToken?: string;
1023
+ timeoutMs?: number;
1024
+ },
1025
+ ): Promise<XurlTweetsResponse> {
1026
+ return runEffectPromise(
1027
+ searchRecentByConversationIdEffect(conversationId, options),
780
1028
  );
781
- return {
782
- data: Array.isArray(payload.data)
783
- ? (payload.data as XurlTweetsResponse["data"])
784
- : [],
785
- includes:
786
- payload.includes && typeof payload.includes === "object"
787
- ? (payload.includes as XurlTweetsResponse["includes"])
788
- : undefined,
789
- meta:
790
- payload.meta && typeof payload.meta === "object"
791
- ? (payload.meta as XurlTweetsResponse["meta"])
792
- : undefined,
793
- };
794
1029
  }
795
1030
 
796
- export async function getTweetById(
1031
+ export function getTweetByIdEffect(
797
1032
  id: string,
798
1033
  { timeoutMs }: { timeoutMs?: number } = {},
799
- ): Promise<XurlTweetsResponse> {
1034
+ ): Effect.Effect<XurlTweetsResponse, Error> {
800
1035
  const query = new URLSearchParams({
801
1036
  expansions: AUTHOR_MEDIA_EXPANSIONS,
802
1037
  "tweet.fields": THREAD_TWEET_FIELDS,
@@ -804,55 +1039,74 @@ export async function getTweetById(
804
1039
  "user.fields": RICH_USER_FIELDS,
805
1040
  });
806
1041
 
807
- const payload = await runJsonCommand(
808
- [`/2/tweets/${id}?${query.toString()}`],
809
- {
810
- timeoutMs,
811
- },
1042
+ return runJsonCommandEffect([`/2/tweets/${id}?${query.toString()}`], {
1043
+ timeoutMs,
1044
+ }).pipe(
1045
+ Effect.map((payload) => {
1046
+ const data =
1047
+ payload.data &&
1048
+ typeof payload.data === "object" &&
1049
+ !Array.isArray(payload.data)
1050
+ ? [payload.data as XurlTweetsResponse["data"][number]]
1051
+ : Array.isArray(payload.data)
1052
+ ? (payload.data as XurlTweetsResponse["data"])
1053
+ : [];
1054
+
1055
+ return {
1056
+ data,
1057
+ includes:
1058
+ payload.includes && typeof payload.includes === "object"
1059
+ ? (payload.includes as XurlTweetsResponse["includes"])
1060
+ : undefined,
1061
+ meta:
1062
+ payload.meta && typeof payload.meta === "object"
1063
+ ? (payload.meta as XurlTweetsResponse["meta"])
1064
+ : undefined,
1065
+ };
1066
+ }),
812
1067
  );
813
- const data =
814
- payload.data &&
815
- typeof payload.data === "object" &&
816
- !Array.isArray(payload.data)
817
- ? [payload.data as XurlTweetsResponse["data"][number]]
818
- : Array.isArray(payload.data)
819
- ? (payload.data as XurlTweetsResponse["data"])
820
- : [];
1068
+ }
821
1069
 
822
- return {
823
- data,
824
- includes:
825
- payload.includes && typeof payload.includes === "object"
826
- ? (payload.includes as XurlTweetsResponse["includes"])
827
- : undefined,
828
- meta:
829
- payload.meta && typeof payload.meta === "object"
830
- ? (payload.meta as XurlTweetsResponse["meta"])
831
- : undefined,
832
- };
1070
+ export function getTweetById(
1071
+ id: string,
1072
+ options: { timeoutMs?: number } = {},
1073
+ ): Promise<XurlTweetsResponse> {
1074
+ return runEffectPromise(getTweetByIdEffect(id, options));
833
1075
  }
834
1076
 
835
- export async function postViaXurl(text: string) {
836
- return runShortcut(["post", text]);
1077
+ export function postViaXurlEffect(text: string) {
1078
+ return runShortcutEffect(["post", text]);
837
1079
  }
838
1080
 
839
- export async function replyViaXurl(tweetId: string, text: string) {
840
- return runShortcut(["reply", tweetId, text]);
1081
+ export function postViaXurl(text: string) {
1082
+ return runEffectPromise(postViaXurlEffect(text));
841
1083
  }
842
1084
 
843
- export async function dmViaXurl(handle: string, text: string) {
844
- return runShortcut([
1085
+ export function replyViaXurlEffect(tweetId: string, text: string) {
1086
+ return runShortcutEffect(["reply", tweetId, text]);
1087
+ }
1088
+
1089
+ export function replyViaXurl(tweetId: string, text: string) {
1090
+ return runEffectPromise(replyViaXurlEffect(tweetId, text));
1091
+ }
1092
+
1093
+ export function dmViaXurlEffect(handle: string, text: string) {
1094
+ return runShortcutEffect([
845
1095
  "dm",
846
1096
  handle.startsWith("@") ? handle : `@${handle}`,
847
1097
  text,
848
1098
  ]);
849
1099
  }
850
1100
 
851
- export async function blockUserViaXurl(
1101
+ export function dmViaXurl(handle: string, text: string) {
1102
+ return runEffectPromise(dmViaXurlEffect(handle, text));
1103
+ }
1104
+
1105
+ export function blockUserViaXurlEffect(
852
1106
  sourceUserId: string,
853
1107
  targetUserId: string,
854
1108
  ) {
855
- return runMutationCommand([
1109
+ return runMutationCommandEffect([
856
1110
  "-X",
857
1111
  "POST",
858
1112
  `/2/users/${sourceUserId}/blocking`,
@@ -861,22 +1115,30 @@ export async function blockUserViaXurl(
861
1115
  ]);
862
1116
  }
863
1117
 
864
- export async function unblockUserViaXurl(
1118
+ export function blockUserViaXurl(sourceUserId: string, targetUserId: string) {
1119
+ return runEffectPromise(blockUserViaXurlEffect(sourceUserId, targetUserId));
1120
+ }
1121
+
1122
+ export function unblockUserViaXurlEffect(
865
1123
  sourceUserId: string,
866
1124
  targetUserId: string,
867
1125
  ) {
868
- return runMutationCommand([
1126
+ return runMutationCommandEffect([
869
1127
  "-X",
870
1128
  "DELETE",
871
1129
  `/2/users/${sourceUserId}/blocking/${targetUserId}`,
872
1130
  ]);
873
1131
  }
874
1132
 
875
- export async function muteUserViaXurl(
1133
+ export function unblockUserViaXurl(sourceUserId: string, targetUserId: string) {
1134
+ return runEffectPromise(unblockUserViaXurlEffect(sourceUserId, targetUserId));
1135
+ }
1136
+
1137
+ export function muteUserViaXurlEffect(
876
1138
  sourceUserId: string,
877
1139
  targetUserId: string,
878
1140
  ) {
879
- return runMutationCommand([
1141
+ return runMutationCommandEffect([
880
1142
  "-X",
881
1143
  "POST",
882
1144
  `/2/users/${sourceUserId}/muting`,
@@ -885,13 +1147,21 @@ export async function muteUserViaXurl(
885
1147
  ]);
886
1148
  }
887
1149
 
888
- export async function unmuteUserViaXurl(
1150
+ export function muteUserViaXurl(sourceUserId: string, targetUserId: string) {
1151
+ return runEffectPromise(muteUserViaXurlEffect(sourceUserId, targetUserId));
1152
+ }
1153
+
1154
+ export function unmuteUserViaXurlEffect(
889
1155
  sourceUserId: string,
890
1156
  targetUserId: string,
891
1157
  ) {
892
- return runMutationCommand([
1158
+ return runMutationCommandEffect([
893
1159
  "-X",
894
1160
  "DELETE",
895
1161
  `/2/users/${sourceUserId}/muting/${targetUserId}`,
896
1162
  ]);
897
1163
  }
1164
+
1165
+ export function unmuteUserViaXurl(sourceUserId: string, targetUserId: string) {
1166
+ return runEffectPromise(unmuteUserViaXurlEffect(sourceUserId, targetUserId));
1167
+ }