birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -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 +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
package/src/lib/xurl.ts CHANGED
@@ -1,8 +1,12 @@
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,
7
+ LiveDataSourceAccount,
5
8
  TransportStatus,
9
+ XurlDmEventsResponse,
6
10
  XurlFollowUsersResponse,
7
11
  XurlMentionsResponse,
8
12
  XurlMentionUser,
@@ -21,6 +25,8 @@ const MEDIA_FIELDS =
21
25
  "variants,preview_image_url,url,duration_ms,alt_text,type,width,height,public_metrics";
22
26
  const RICH_USER_FIELDS =
23
27
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type";
28
+ const DM_EVENT_FIELDS =
29
+ "attachments,created_at,dm_conversation_id,entities,event_type,id,participant_ids,referenced_tweets,sender_id,text";
24
30
  const THREAD_TWEET_FIELDS =
25
31
  "created_at,conversation_id,entities,public_metrics,referenced_tweets,in_reply_to_user_id,attachments";
26
32
  // X bookmarks pagination truncates above 90 until this bug is fixed:
@@ -31,6 +37,18 @@ type TimelineCollectionEndpoint = "liked_tweets" | "bookmarks";
31
37
  type JsonCommandOptions = {
32
38
  timeoutMs?: number;
33
39
  deadlineMs?: number;
40
+ signal?: AbortSignal;
41
+ onAttempt?: (attempt: XurlJsonCommandAttempt) => void;
42
+ };
43
+ export interface XurlJsonCommandAttempt {
44
+ args: readonly string[];
45
+ attempt: number;
46
+ status: "ok" | "rate_limited" | "error";
47
+ error?: Error;
48
+ }
49
+ type OAuth2UsernameCandidate = {
50
+ app?: string;
51
+ username?: string;
34
52
  };
35
53
 
36
54
  let transportStatusCache:
@@ -47,11 +65,22 @@ let authenticatedUserCache:
47
65
  value?: Record<string, unknown> | null;
48
66
  }
49
67
  | undefined;
68
+ const oauth2CandidateCache = new Map<
69
+ string,
70
+ { expiresAt: number; value: OAuth2UsernameCandidate }
71
+ >();
50
72
 
51
73
  function liveWritesDisabled() {
52
74
  return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
53
75
  }
54
76
 
77
+ function e2eFakeLiveWritesEnabled() {
78
+ return (
79
+ process.env.BIRDCLAW_E2E === "1" &&
80
+ process.env.BIRDCLAW_E2E_FAKE_LIVE_WRITES === "1"
81
+ );
82
+ }
83
+
55
84
  function getJsonRetryBaseDelayMs() {
56
85
  const value = Number(process.env.BIRDCLAW_XURL_RETRY_BASE_MS ?? "2000");
57
86
  return Number.isFinite(value) && value >= 0 ? value : 2000;
@@ -90,6 +119,10 @@ function formatXurlCommandError(error: unknown, args: string[]) {
90
119
  return new Error(formatExecError(error, `xurl ${args.join(" ")} failed`));
91
120
  }
92
121
 
122
+ function normalizeError(error: unknown) {
123
+ return error instanceof Error ? error : new Error(String(error));
124
+ }
125
+
93
126
  function parseErrorPayload(error: unknown) {
94
127
  const stdout =
95
128
  typeof error === "object" &&
@@ -123,6 +156,17 @@ function getRetryDelayMs(error: unknown, attempt: number) {
123
156
  return Math.min(baseDelay * 2 ** attempt, 30_000);
124
157
  }
125
158
 
159
+ function emitJsonCommandAttempt(
160
+ options: JsonCommandOptions,
161
+ attempt: XurlJsonCommandAttempt,
162
+ ) {
163
+ try {
164
+ options.onAttempt?.(attempt);
165
+ } catch {
166
+ // Telemetry observers must not affect xurl command behavior.
167
+ }
168
+ }
169
+
126
170
  function capTimelineCollectionMaxResults(
127
171
  collection: TimelineCollectionEndpoint,
128
172
  maxResults: number,
@@ -133,28 +177,23 @@ function capTimelineCollectionMaxResults(
133
177
  : maxResults;
134
178
  }
135
179
 
136
- async function sleep(ms: number) {
137
- if (ms <= 0) {
138
- return;
139
- }
140
- await new Promise((resolve) => setTimeout(resolve, ms));
141
- }
142
-
143
180
  export function resetTransportStatusCache() {
144
181
  transportStatusCache = undefined;
145
182
  }
146
183
 
147
184
  export function resetAuthenticatedUserCache() {
148
185
  authenticatedUserCache = undefined;
186
+ oauth2CandidateCache.clear();
149
187
  }
150
188
 
151
- async function hasXurl(): Promise<boolean> {
152
- try {
153
- await execFileAsync("xurl", ["version"]);
154
- return true;
155
- } catch {
156
- return false;
157
- }
189
+ function hasXurlEffect() {
190
+ return Effect.tryPromise({
191
+ try: () => execFileAsync("xurl", ["version"]),
192
+ catch: normalizeError,
193
+ }).pipe(
194
+ Effect.as(true),
195
+ Effect.catchAll(() => Effect.succeed(false)),
196
+ );
158
197
  }
159
198
 
160
199
  function isUnauthenticatedXurlStatus(status: string) {
@@ -163,164 +202,553 @@ function isUnauthenticatedXurlStatus(status: string) {
163
202
  );
164
203
  }
165
204
 
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();
205
+ function readTransportStatusEffect(): Effect.Effect<TransportStatus, never> {
206
+ return Effect.gen(function* () {
207
+ const installed = yield* hasXurlEffect();
178
208
  if (!installed) {
179
209
  return {
180
210
  installed: false,
181
- availableTransport: "local",
211
+ availableTransport: "local" as const,
182
212
  statusText: "xurl not installed. local mode active.",
183
213
  };
184
214
  }
185
215
 
186
- try {
187
- const { stdout } = await execFileAsync("xurl", ["auth", "status"]);
188
- const rawStatus = stdout.trim();
216
+ return yield* Effect.tryPromise({
217
+ try: () => execFileAsync("xurl", ["auth", "status"]),
218
+ catch: (cause) => cause,
219
+ }).pipe(
220
+ Effect.map(({ stdout }) => {
221
+ const rawStatus = stdout.trim();
222
+
223
+ if (isUnauthenticatedXurlStatus(rawStatus)) {
224
+ return {
225
+ installed: true,
226
+ availableTransport: "local" as const,
227
+ statusText:
228
+ "xurl installed but not authenticated. local (bird) mode active.",
229
+ rawStatus,
230
+ };
231
+ }
189
232
 
190
- if (isUnauthenticatedXurlStatus(rawStatus)) {
191
233
  return {
192
234
  installed: true,
193
- availableTransport: "local",
194
- statusText:
195
- "xurl installed but not authenticated. local (bird) mode active.",
235
+ availableTransport: "xurl" as const,
236
+ statusText: "xurl available",
196
237
  rawStatus,
197
238
  };
198
- }
239
+ }),
240
+ Effect.catchAll((error) =>
241
+ Effect.succeed({
242
+ installed: true,
243
+ availableTransport: "local" as const,
244
+ statusText: `xurl detected but auth unavailable: ${
245
+ error instanceof Error ? error.message : "unknown error"
246
+ }`,
247
+ }),
248
+ ),
249
+ );
250
+ });
251
+ }
199
252
 
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
- };
253
+ export function getTransportStatusEffect() {
254
+ return Effect.gen(function* () {
255
+ const now = Date.now();
256
+ if (transportStatusCache?.value && transportStatusCache.expiresAt > now) {
257
+ return transportStatusCache.value;
214
258
  }
215
- })();
216
259
 
217
- transportStatusCache = {
218
- expiresAt: 0,
219
- pending,
220
- };
260
+ if (transportStatusCache?.pending) {
261
+ const status = yield* Effect.tryPromise({
262
+ try: () => transportStatusCache?.pending ?? Promise.resolve(undefined),
263
+ catch: normalizeError,
264
+ });
265
+ if (status) return status;
266
+ }
221
267
 
222
- try {
223
- const status = await pending;
268
+ const pending = runEffectPromise(readTransportStatusEffect());
269
+
270
+ transportStatusCache = {
271
+ expiresAt: 0,
272
+ pending,
273
+ };
274
+
275
+ const status = yield* Effect.tryPromise({
276
+ try: () => pending,
277
+ catch: normalizeError,
278
+ }).pipe(
279
+ Effect.catchAll((error) =>
280
+ Effect.sync(() => {
281
+ transportStatusCache = undefined;
282
+ }).pipe(Effect.flatMap(() => Effect.fail(error))),
283
+ ),
284
+ );
224
285
  transportStatusCache = {
225
286
  expiresAt: Date.now() + TRANSPORT_STATUS_TTL_MS,
226
287
  value: status,
227
288
  };
228
289
  return status;
229
- } catch (error) {
230
- transportStatusCache = undefined;
231
- throw error;
232
- }
290
+ });
291
+ }
292
+
293
+ export function getTransportStatus(): Promise<TransportStatus> {
294
+ return runEffectPromise(getTransportStatusEffect());
233
295
  }
234
296
 
235
- async function runShortcut(
297
+ function runShortcutEffect(args: string[]) {
298
+ return Effect.gen(function* () {
299
+ if (liveWritesDisabled()) {
300
+ if (e2eFakeLiveWritesEnabled()) {
301
+ return { ok: true, output: "e2e fake live write" };
302
+ }
303
+ return { ok: false, output: "live writes disabled" };
304
+ }
305
+
306
+ return yield* Effect.tryPromise({
307
+ try: () => execFileAsync("xurl", args),
308
+ catch: normalizeError,
309
+ }).pipe(
310
+ Effect.map(({ stdout, stderr }) => ({
311
+ ok: true,
312
+ output: stdout || stderr,
313
+ })),
314
+ Effect.catchAll((error) =>
315
+ Effect.succeed({
316
+ ok: false,
317
+ output: formatExecError(error, "xurl execution failed"),
318
+ }),
319
+ ),
320
+ );
321
+ });
322
+ }
323
+
324
+ function execXurlJsonEffect(
236
325
  args: string[],
237
- ): Promise<{ ok: boolean; output: string }> {
238
- if (liveWritesDisabled()) {
239
- return { ok: true, output: "live writes disabled" };
240
- }
326
+ timeoutMs?: number,
327
+ signal?: AbortSignal,
328
+ ): Effect.Effect<{ stdout: string; stderr: string }, Error> {
329
+ return Effect.tryPromise({
330
+ try: () => {
331
+ const controller =
332
+ (typeof timeoutMs === "number" &&
333
+ Number.isFinite(timeoutMs) &&
334
+ timeoutMs > 0) ||
335
+ signal
336
+ ? new AbortController()
337
+ : undefined;
338
+ if (
339
+ typeof timeoutMs === "number" &&
340
+ Number.isFinite(timeoutMs) &&
341
+ timeoutMs <= 0
342
+ ) {
343
+ throw new Error("xurl command timed out");
344
+ }
345
+ if (signal?.aborted) {
346
+ throw new Error("xurl command aborted");
347
+ }
348
+ const onAbort = () => controller?.abort();
349
+ signal?.addEventListener("abort", onAbort, { once: true });
350
+ const timeout =
351
+ controller &&
352
+ typeof timeoutMs === "number" &&
353
+ Number.isFinite(timeoutMs) &&
354
+ timeoutMs > 0
355
+ ? setTimeout(() => controller.abort(), timeoutMs)
356
+ : undefined;
357
+ const result = controller
358
+ ? execFileAsync("xurl", args, { signal: controller.signal })
359
+ : execFileAsync("xurl", args);
360
+ return result.finally(() => {
361
+ signal?.removeEventListener("abort", onAbort);
362
+ if (timeout) {
363
+ clearTimeout(timeout);
364
+ }
365
+ });
366
+ },
367
+ catch: normalizeError,
368
+ });
369
+ }
241
370
 
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
- };
371
+ function getRemainingTimeoutMs(deadlineMs?: number) {
372
+ if (deadlineMs === undefined) return undefined;
373
+ const timeoutMs = Math.max(0, deadlineMs - Date.now());
374
+ if (timeoutMs <= 0) {
375
+ throw new Error("xurl OAuth2 fallback timed out");
250
376
  }
377
+ return timeoutMs;
378
+ }
379
+
380
+ function execXurlTextEffect(
381
+ args: string[],
382
+ deadlineMs?: number,
383
+ ): Effect.Effect<{ stdout: string; stderr: string }, Error> {
384
+ return Effect.tryPromise({
385
+ try: () => {
386
+ const timeoutMs = getRemainingTimeoutMs(deadlineMs);
387
+ const controller =
388
+ typeof timeoutMs === "number" &&
389
+ Number.isFinite(timeoutMs) &&
390
+ timeoutMs > 0
391
+ ? new AbortController()
392
+ : undefined;
393
+ const timeout = controller
394
+ ? setTimeout(() => controller.abort(), timeoutMs)
395
+ : undefined;
396
+ const result = controller
397
+ ? execFileAsync("xurl", args, { signal: controller.signal })
398
+ : execFileAsync("xurl", args);
399
+ return result.finally(() => {
400
+ if (timeout) {
401
+ clearTimeout(timeout);
402
+ }
403
+ });
404
+ },
405
+ catch: normalizeError,
406
+ });
407
+ }
408
+
409
+ function parseJsonPayloadEffect(
410
+ stdout: string,
411
+ args: string[],
412
+ ): Effect.Effect<Record<string, unknown>, Error> {
413
+ return Effect.try({
414
+ try: () => JSON.parse(stdout) as Record<string, unknown>,
415
+ catch: (error) => formatXurlCommandError(error, args),
416
+ });
251
417
  }
252
418
 
253
- async function runJsonCommand(
419
+ function runJsonCommandEffect(
254
420
  args: string[],
255
421
  options: JsonCommandOptions = {},
256
422
  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()
423
+ ): Effect.Effect<Record<string, unknown>, Error> {
424
+ return Effect.gen(function* () {
425
+ const deadlineMs =
426
+ options.deadlineMs ??
427
+ (typeof options.timeoutMs === "number" &&
428
+ Number.isFinite(options.timeoutMs) &&
429
+ options.timeoutMs > 0
430
+ ? Date.now() + options.timeoutMs
431
+ : undefined);
432
+ const timeoutMs = deadlineMs
433
+ ? Math.max(0, deadlineMs - Date.now())
271
434
  : undefined;
272
- const timeout = controller
273
- ? setTimeout(() => controller.abort(), timeoutMs)
435
+ return yield* execXurlJsonEffect(args, timeoutMs, options.signal).pipe(
436
+ Effect.flatMap(({ stdout }) => parseJsonPayloadEffect(stdout, args)),
437
+ Effect.tap(() =>
438
+ Effect.sync(() =>
439
+ emitJsonCommandAttempt(options, { args, attempt, status: "ok" }),
440
+ ),
441
+ ),
442
+ Effect.catchAll((error) => {
443
+ const retryDelayMs = getRetryDelayMs(error, attempt);
444
+ emitJsonCommandAttempt(options, {
445
+ args,
446
+ attempt,
447
+ status: retryDelayMs === null ? "error" : "rate_limited",
448
+ error,
449
+ });
450
+ if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
451
+ return Effect.fail(formatXurlCommandError(error, args));
452
+ }
453
+ if (options.signal?.aborted) {
454
+ return Effect.fail(formatXurlCommandError(error, args));
455
+ }
456
+ const remainingMs = deadlineMs
457
+ ? Math.max(0, deadlineMs - Date.now())
458
+ : undefined;
459
+ if (remainingMs !== undefined && retryDelayMs >= remainingMs) {
460
+ return Effect.fail(formatXurlCommandError(error, args));
461
+ }
462
+
463
+ return Effect.sleep(retryDelayMs).pipe(
464
+ Effect.flatMap(() =>
465
+ runJsonCommandEffect(args, { ...options, deadlineMs }, attempt + 1),
466
+ ),
467
+ );
468
+ }),
469
+ );
470
+ });
471
+ }
472
+
473
+ function cleanXurlUsernameLabel(username?: string) {
474
+ const label = username?.trim().replace(/^@/, "");
475
+ return label &&
476
+ label !== "-" &&
477
+ label !== "–" &&
478
+ label !== "(none)" &&
479
+ label.toLowerCase() !== "none" &&
480
+ label.toLowerCase() !== "unknown" &&
481
+ /^[^\s]{1,128}$/.test(label)
482
+ ? label
274
483
  : undefined;
484
+ }
275
485
 
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);
486
+ function comparableXurlUsername(username?: string) {
487
+ return cleanXurlUsernameLabel(username)?.toLowerCase();
488
+ }
489
+
490
+ function cleanXurlAppLabel(app?: string) {
491
+ const label = app?.trim();
492
+ return label && /^[^\s]{1,128}$/.test(label) ? label : undefined;
493
+ }
494
+
495
+ function parseOAuth2UsernamesFromStatus(rawStatus: string) {
496
+ const seen = new Set<string>();
497
+ const usernames: OAuth2UsernameCandidate[] = [];
498
+ let currentApp: string | undefined;
499
+ for (const line of rawStatus.split(/\r?\n/)) {
500
+ const appMatch = line.match(/^\s*(?:▸\s*)?([^\s]+)\s+\[client_id:/);
501
+ if (appMatch) {
502
+ currentApp = cleanXurlAppLabel(appMatch[1]);
503
+ continue;
285
504
  }
286
- const remainingMs = deadlineMs
287
- ? Math.max(0, deadlineMs - Date.now())
505
+ const oauthMatch = line.match(/\boauth2:\s*([^\s]+)/);
506
+ if (oauthMatch) {
507
+ const username = cleanXurlUsernameLabel(oauthMatch[1]);
508
+ const key = username
509
+ ? `${currentApp ?? "default"}:${username}`
510
+ : undefined;
511
+ if (username && key && !seen.has(key)) {
512
+ seen.add(key);
513
+ usernames.push({ app: currentApp, username });
514
+ }
515
+ }
516
+ }
517
+ return usernames;
518
+ }
519
+
520
+ function readOAuth2UsernameCandidatesEffect(
521
+ deadlineMs?: number,
522
+ ): Effect.Effect<OAuth2UsernameCandidate[], never> {
523
+ return execXurlTextEffect(["auth", "status"], deadlineMs).pipe(
524
+ Effect.map(({ stdout }) => parseOAuth2UsernamesFromStatus(stdout)),
525
+ Effect.catchAll(() => Effect.succeed([])),
526
+ );
527
+ }
528
+
529
+ export function readXurlOAuth2AccountsEffect(): Effect.Effect<
530
+ LiveDataSourceAccount[],
531
+ never
532
+ > {
533
+ return readOAuth2UsernameCandidatesEffect().pipe(
534
+ Effect.map((candidates) =>
535
+ candidates.map((candidate) => ({
536
+ ...(candidate.app ? { app: candidate.app } : {}),
537
+ ...(candidate.username ? { username: candidate.username } : {}),
538
+ })),
539
+ ),
540
+ );
541
+ }
542
+
543
+ export function readXurlOAuth2Accounts(): Promise<LiveDataSourceAccount[]> {
544
+ return runEffectPromise(readXurlOAuth2AccountsEffect());
545
+ }
546
+
547
+ function lookupOAuth2UsernameForAccountEffect(
548
+ expectedUsername: string,
549
+ attemptedUsernames: Set<string>,
550
+ deadlineMs?: number,
551
+ knownCandidates?: OAuth2UsernameCandidate[],
552
+ ) {
553
+ return Effect.gen(function* () {
554
+ const expected = comparableXurlUsername(expectedUsername);
555
+ if (!expected) return undefined;
556
+
557
+ const candidates =
558
+ knownCandidates ??
559
+ (yield* readOAuth2UsernameCandidatesEffect(deadlineMs));
560
+ for (const candidate of candidates) {
561
+ const candidateKey = `${candidate.app ?? "default"}:${candidate.username}`;
562
+ if (attemptedUsernames.has(candidateKey)) continue;
563
+ const payload = yield* runJsonCommandEffect(
564
+ oauth2ArgsForCandidate(candidate, ["/2/users/me"]),
565
+ { deadlineMs },
566
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)));
567
+ const user = payload ? authenticatedUserFromPayload(payload) : null;
568
+ const actual = comparableXurlUsername(String(user?.username ?? ""));
569
+ if (actual === expected) {
570
+ return candidate;
571
+ }
572
+ }
573
+
574
+ return undefined;
575
+ });
576
+ }
577
+
578
+ function oauth2ArgsForCandidate(
579
+ candidate: OAuth2UsernameCandidate | undefined,
580
+ args: string[],
581
+ ) {
582
+ return [
583
+ ...(candidate?.app ? ["--app", candidate.app] : []),
584
+ "--auth",
585
+ "oauth2",
586
+ ...(candidate?.username ? ["--username", candidate.username] : []),
587
+ ...args,
588
+ ];
589
+ }
590
+
591
+ function configuredOAuth2Candidate(primaryUsername: string | undefined) {
592
+ const app = cleanXurlAppLabel(process.env.BIRDCLAW_XURL_OAUTH2_APP);
593
+ const username = cleanXurlUsernameLabel(
594
+ process.env.BIRDCLAW_XURL_OAUTH2_USERNAME,
595
+ );
596
+ if (!app && !username) return undefined;
597
+ const effectiveUsername = username ?? primaryUsername;
598
+ if (!app && !effectiveUsername) return undefined;
599
+ return {
600
+ ...(app ? { app } : {}),
601
+ ...(effectiveUsername ? { username: effectiveUsername } : {}),
602
+ };
603
+ }
604
+
605
+ function runOAuth2JsonCommandEffect({
606
+ args,
607
+ username,
608
+ options,
609
+ useConfiguredCandidate = true,
610
+ }: {
611
+ args: string[];
612
+ username?: string;
613
+ options?: JsonCommandOptions;
614
+ useConfiguredCandidate?: boolean;
615
+ }) {
616
+ const primaryUsername = cleanXurlUsernameLabel(username);
617
+ return Effect.gen(function* () {
618
+ const deadlineMs =
619
+ options?.deadlineMs ??
620
+ (typeof options?.timeoutMs === "number" &&
621
+ Number.isFinite(options.timeoutMs) &&
622
+ options.timeoutMs > 0
623
+ ? Date.now() + options.timeoutMs
624
+ : undefined);
625
+ const scopedOptions = { ...options, deadlineMs };
626
+ let authCandidate: OAuth2UsernameCandidate | undefined = primaryUsername
627
+ ? { username: primaryUsername }
288
628
  : undefined;
289
- if (remainingMs !== undefined && retryDelayMs >= remainingMs) {
290
- throw formatXurlCommandError(error, args);
629
+ const configuredCandidate = useConfiguredCandidate
630
+ ? configuredOAuth2Candidate(primaryUsername)
631
+ : undefined;
632
+ if (configuredCandidate) {
633
+ authCandidate = configuredCandidate;
634
+ } else if (primaryUsername) {
635
+ const cacheKey = primaryUsername.toLowerCase();
636
+ const cachedCandidate = oauth2CandidateCache.get(cacheKey);
637
+ if (cachedCandidate && cachedCandidate.expiresAt > Date.now()) {
638
+ authCandidate = cachedCandidate.value;
639
+ } else {
640
+ const candidates =
641
+ yield* readOAuth2UsernameCandidatesEffect(deadlineMs);
642
+ const primaryCandidates = candidates.filter(
643
+ (candidate) => candidate.username === primaryUsername,
644
+ );
645
+ if (primaryCandidates.length === 1) {
646
+ authCandidate = primaryCandidates[0];
647
+ } else if (primaryCandidates.length > 1) {
648
+ const verifiedUsername = yield* lookupOAuth2UsernameForAccountEffect(
649
+ primaryUsername,
650
+ new Set(),
651
+ deadlineMs,
652
+ candidates,
653
+ );
654
+ authCandidate = verifiedUsername ??
655
+ primaryCandidates[0] ?? { username: primaryUsername };
656
+ } else {
657
+ const fallbackUsername = yield* lookupOAuth2UsernameForAccountEffect(
658
+ primaryUsername,
659
+ new Set(),
660
+ deadlineMs,
661
+ candidates,
662
+ );
663
+ if (fallbackUsername) {
664
+ authCandidate = fallbackUsername;
665
+ }
666
+ }
667
+ if (authCandidate) {
668
+ oauth2CandidateCache.set(cacheKey, {
669
+ expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
670
+ value: authCandidate,
671
+ });
672
+ }
673
+ }
291
674
  }
292
675
 
293
- await sleep(retryDelayMs);
294
- return runJsonCommand(args, { ...options, deadlineMs }, attempt + 1);
295
- } finally {
296
- if (timeout) {
297
- clearTimeout(timeout);
676
+ if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
677
+ return yield* Effect.fail(new Error("xurl OAuth2 fallback timed out"));
298
678
  }
299
- }
679
+
680
+ return yield* runJsonCommandEffect(
681
+ oauth2ArgsForCandidate(authCandidate, args),
682
+ scopedOptions,
683
+ ).pipe(
684
+ Effect.catchAll((error) => {
685
+ if (!primaryUsername) {
686
+ return Effect.fail(error);
687
+ }
688
+ oauth2CandidateCache.delete(primaryUsername.toLowerCase());
689
+ const attempted = new Set([
690
+ authCandidate
691
+ ? `${authCandidate.app ?? "default"}:${authCandidate.username}`
692
+ : `default:${primaryUsername}`,
693
+ ]);
694
+ if (authCandidate?.username !== primaryUsername) {
695
+ attempted.add(`default:${primaryUsername}`);
696
+ }
697
+ return lookupOAuth2UsernameForAccountEffect(
698
+ primaryUsername,
699
+ attempted,
700
+ deadlineMs,
701
+ ).pipe(
702
+ Effect.flatMap((fallbackUsername) => {
703
+ if (!fallbackUsername) {
704
+ return Effect.fail(error);
705
+ }
706
+ oauth2CandidateCache.set(primaryUsername.toLowerCase(), {
707
+ expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
708
+ value: fallbackUsername,
709
+ });
710
+ return runJsonCommandEffect(
711
+ oauth2ArgsForCandidate(fallbackUsername, args),
712
+ scopedOptions,
713
+ );
714
+ }),
715
+ Effect.catchAll(() => Effect.fail(error)),
716
+ );
717
+ }),
718
+ );
719
+ });
300
720
  }
301
721
 
302
- async function runMutationCommand(args: string[]) {
303
- if (liveWritesDisabled()) {
304
- return { ok: true, output: "live writes disabled" };
305
- }
722
+ function runMutationCommandEffect(args: string[]) {
723
+ return Effect.gen(function* () {
724
+ if (liveWritesDisabled()) {
725
+ if (e2eFakeLiveWritesEnabled()) {
726
+ return { ok: true, output: "e2e fake live write" };
727
+ }
728
+ return { ok: false, output: "live writes disabled" };
729
+ }
306
730
 
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
- }
731
+ return yield* Effect.tryPromise({
732
+ try: () => execFileAsync("xurl", args),
733
+ catch: normalizeError,
734
+ }).pipe(
735
+ Effect.map(({ stdout, stderr }) => ({
736
+ ok: true,
737
+ output: stdout || stderr || "ok",
738
+ })),
739
+ Effect.catchAll((error) =>
740
+ Effect.succeed({
741
+ ok: false,
742
+ output: formatExecError(error, "xurl execution failed"),
743
+ }),
744
+ ),
745
+ );
746
+ });
319
747
  }
320
748
 
321
- export async function lookupUsersByIds(ids: string[]) {
749
+ export function lookupUsersByIdsEffect(ids: string[]) {
322
750
  if (ids.length === 0) {
323
- return [];
751
+ return Effect.succeed([]);
324
752
  }
325
753
 
326
754
  const query = new URLSearchParams({
@@ -328,14 +756,28 @@ export async function lookupUsersByIds(ids: string[]) {
328
756
  "user.fields":
329
757
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
330
758
  });
331
- const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
332
- const data = payload.data;
333
- return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
759
+ return runJsonCommandEffect([`/2/users?${query.toString()}`]).pipe(
760
+ Effect.map((payload) =>
761
+ Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
762
+ ),
763
+ );
334
764
  }
335
765
 
336
- export async function lookupUsersByHandles(handles: string[]) {
766
+ export function lookupUsersByIds(ids: string[]) {
767
+ return runEffectPromise(lookupUsersByIdsEffect(ids));
768
+ }
769
+
770
+ export function lookupUsersByHandlesEffect(
771
+ handles: string[],
772
+ options: {
773
+ auth?: "oauth2";
774
+ username?: string;
775
+ signal?: AbortSignal;
776
+ useConfiguredCandidate?: boolean;
777
+ } = {},
778
+ ) {
337
779
  if (handles.length === 0) {
338
- return [];
780
+ return Effect.succeed([]);
339
781
  }
340
782
 
341
783
  const query = new URLSearchParams({
@@ -343,52 +785,135 @@ export async function lookupUsersByHandles(handles: string[]) {
343
785
  "user.fields":
344
786
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
345
787
  });
346
- const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
788
+ const args = [`/2/users/by?${query.toString()}`];
789
+ const command =
790
+ options.auth === "oauth2"
791
+ ? runOAuth2JsonCommandEffect({
792
+ args,
793
+ username: options.username,
794
+ options: { signal: options.signal },
795
+ useConfiguredCandidate: options.useConfiguredCandidate,
796
+ })
797
+ : runJsonCommandEffect(args, { signal: options.signal });
798
+ return command.pipe(
799
+ Effect.map((payload) =>
800
+ Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
801
+ ),
802
+ );
803
+ }
804
+
805
+ export function lookupUsersByHandles(
806
+ handles: string[],
807
+ options: {
808
+ auth?: "oauth2";
809
+ username?: string;
810
+ signal?: AbortSignal;
811
+ useConfiguredCandidate?: boolean;
812
+ } = {},
813
+ ) {
814
+ return runEffectPromise(lookupUsersByHandlesEffect(handles, options));
815
+ }
816
+
817
+ function authenticatedUserFromPayload(payload: Record<string, unknown>) {
347
818
  const data = payload.data;
348
- return Array.isArray(data) ? (data as XurlMentionUser[]) : [];
819
+ return data && typeof data === "object"
820
+ ? (data as Record<string, unknown>)
821
+ : null;
349
822
  }
350
823
 
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
- }
824
+ export function lookupAuthenticatedUserFreshEffect() {
825
+ return runJsonCommandEffect(["whoami"]).pipe(
826
+ Effect.map(authenticatedUserFromPayload),
827
+ );
828
+ }
360
829
 
361
- if (authenticatedUserCache?.pending) {
362
- return authenticatedUserCache.pending;
363
- }
830
+ export function lookupAuthenticatedOAuth2UserEffect(username?: string) {
831
+ return runOAuth2JsonCommandEffect({
832
+ args: ["whoami"],
833
+ username,
834
+ }).pipe(Effect.map(authenticatedUserFromPayload));
835
+ }
364
836
 
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
- };
837
+ export function lookupAuthenticatedUserEffect() {
838
+ return Effect.gen(function* () {
839
+ const now = Date.now();
840
+ if (
841
+ authenticatedUserCache &&
842
+ "value" in authenticatedUserCache &&
843
+ authenticatedUserCache.expiresAt > now
844
+ ) {
845
+ return authenticatedUserCache.value ?? null;
846
+ }
377
847
 
378
- try {
379
- const value = await pending;
848
+ if (authenticatedUserCache?.pending) {
849
+ return yield* Effect.tryPromise({
850
+ try: () => authenticatedUserCache?.pending ?? Promise.resolve(null),
851
+ catch: normalizeError,
852
+ });
853
+ }
854
+
855
+ const pending = runEffectPromise(lookupAuthenticatedUserFreshEffect());
856
+
857
+ authenticatedUserCache = {
858
+ expiresAt: 0,
859
+ pending,
860
+ };
861
+
862
+ const value = yield* Effect.tryPromise({
863
+ try: () => pending,
864
+ catch: normalizeError,
865
+ }).pipe(
866
+ Effect.catchAll((error) =>
867
+ Effect.sync(() => {
868
+ authenticatedUserCache = undefined;
869
+ }).pipe(Effect.flatMap(() => Effect.fail(error))),
870
+ ),
871
+ );
380
872
  authenticatedUserCache = {
381
873
  expiresAt: Date.now() + AUTHENTICATED_USER_TTL_MS,
382
874
  value,
383
875
  };
384
876
  return value;
385
- } catch (error) {
386
- authenticatedUserCache = undefined;
387
- throw error;
388
- }
877
+ });
878
+ }
879
+
880
+ export function lookupAuthenticatedUser() {
881
+ return runEffectPromise(lookupAuthenticatedUserEffect());
389
882
  }
390
883
 
391
- export async function listMentionsViaXurl({
884
+ export function lookupAuthenticatedUserFresh() {
885
+ return runEffectPromise(lookupAuthenticatedUserFreshEffect());
886
+ }
887
+
888
+ function resolveUserIdEffect({
889
+ username,
890
+ userId,
891
+ }: {
892
+ username?: string;
893
+ userId?: string;
894
+ }) {
895
+ return Effect.gen(function* () {
896
+ if (userId) return userId;
897
+ if (username) {
898
+ const [user] = yield* lookupUsersByHandlesEffect([username]);
899
+ if (!user?.id) {
900
+ return yield* Effect.fail(
901
+ new Error(`Could not resolve Twitter user id for @${username}`),
902
+ );
903
+ }
904
+ return String(user.id);
905
+ }
906
+ const user = yield* lookupAuthenticatedUserEffect();
907
+ if (!user?.id) {
908
+ return yield* Effect.fail(
909
+ new Error("Could not resolve authenticated Twitter user id"),
910
+ );
911
+ }
912
+ return String(user.id);
913
+ });
914
+ }
915
+
916
+ export function listMentionsViaXurlEffect({
392
917
  maxResults,
393
918
  username,
394
919
  userId,
@@ -402,45 +927,97 @@ export async function listMentionsViaXurl({
402
927
  paginationToken?: string;
403
928
  sinceId?: string;
404
929
  startTime?: string;
930
+ }): Effect.Effect<XurlMentionsResponse, Error> {
931
+ return Effect.gen(function* () {
932
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
933
+ const query = new URLSearchParams({
934
+ max_results: String(maxResults),
935
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
936
+ "tweet.fields": "created_at,conversation_id,entities,public_metrics",
937
+ "media.fields": MEDIA_FIELDS,
938
+ "user.fields":
939
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
940
+ });
941
+ if (paginationToken) {
942
+ query.set("pagination_token", paginationToken);
943
+ }
944
+ if (sinceId) {
945
+ query.set("since_id", sinceId);
946
+ }
947
+ if (startTime) {
948
+ query.set("start_time", startTime);
949
+ }
950
+
951
+ const payload = yield* runOAuth2JsonCommandEffect({
952
+ args: [`/2/users/${resolvedUserId}/mentions?${query.toString()}`],
953
+ username,
954
+ });
955
+ return toXurlMentionsResponse(payload);
956
+ });
957
+ }
958
+
959
+ export function listMentionsViaXurl(options: {
960
+ maxResults: number;
961
+ username?: string;
962
+ userId?: string;
963
+ paginationToken?: string;
964
+ sinceId?: string;
965
+ startTime?: string;
405
966
  }): 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);
967
+ return runEffectPromise(listMentionsViaXurlEffect(options));
968
+ }
969
+
970
+ export function listHomeTimelineViaXurlEffect({
971
+ maxResults,
972
+ username,
973
+ userId,
974
+ paginationToken,
975
+ timeoutMs,
976
+ }: {
977
+ maxResults: number;
978
+ username?: string;
979
+ userId?: string;
980
+ paginationToken?: string;
981
+ timeoutMs?: number;
982
+ }): Effect.Effect<XurlMentionsResponse, Error> {
983
+ return Effect.gen(function* () {
984
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
985
+ const query = new URLSearchParams({
986
+ max_results: String(maxResults),
987
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
988
+ "tweet.fields":
989
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
990
+ "media.fields": MEDIA_FIELDS,
991
+ "user.fields": RICH_USER_FIELDS,
992
+ });
993
+ if (paginationToken) {
994
+ query.set("pagination_token", paginationToken);
420
995
  }
421
- }
422
996
 
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",
997
+ const payload = yield* runOAuth2JsonCommandEffect({
998
+ args: [
999
+ `/2/users/${resolvedUserId}/timelines/reverse_chronological?${query.toString()}`,
1000
+ ],
1001
+ username,
1002
+ options: { timeoutMs },
1003
+ });
1004
+ return toXurlMentionsResponse(payload);
430
1005
  });
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
- }
1006
+ }
440
1007
 
441
- const payload = await runJsonCommand([
442
- `/2/users/${resolvedUserId}/mentions?${query.toString()}`,
443
- ]);
1008
+ export function listHomeTimelineViaXurl(options: {
1009
+ maxResults: number;
1010
+ username?: string;
1011
+ userId?: string;
1012
+ paginationToken?: string;
1013
+ timeoutMs?: number;
1014
+ }): Promise<XurlMentionsResponse> {
1015
+ return runEffectPromise(listHomeTimelineViaXurlEffect(options));
1016
+ }
1017
+
1018
+ function toXurlMentionsResponse(
1019
+ payload: Record<string, unknown>,
1020
+ ): XurlMentionsResponse {
444
1021
  return {
445
1022
  data: Array.isArray(payload.data)
446
1023
  ? (payload.data as XurlMentionsResponse["data"])
@@ -456,7 +1033,7 @@ export async function listMentionsViaXurl({
456
1033
  };
457
1034
  }
458
1035
 
459
- async function listTimelineCollectionViaXurl({
1036
+ function listTimelineCollectionViaXurlEffect({
460
1037
  collection,
461
1038
  maxResults,
462
1039
  username,
@@ -470,88 +1047,128 @@ async function listTimelineCollectionViaXurl({
470
1047
  userId?: string;
471
1048
  isPaginatedWalk?: boolean;
472
1049
  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);
1050
+ }): Effect.Effect<XurlMentionsResponse, Error> {
1051
+ return Effect.gen(function* () {
1052
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
1053
+ const requestMaxResults = capTimelineCollectionMaxResults(
1054
+ collection,
1055
+ maxResults,
1056
+ isPaginatedWalk,
1057
+ );
1058
+ const query = new URLSearchParams({
1059
+ max_results: String(requestMaxResults),
1060
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
1061
+ "tweet.fields":
1062
+ "created_at,conversation_id,entities,public_metrics,referenced_tweets",
1063
+ "media.fields": MEDIA_FIELDS,
1064
+ "user.fields":
1065
+ "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
1066
+ });
1067
+ if (paginationToken) {
1068
+ query.set("pagination_token", paginationToken);
488
1069
  }
489
- }
490
1070
 
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",
1071
+ const payload = yield* runOAuth2JsonCommandEffect({
1072
+ args: [`/2/users/${resolvedUserId}/${collection}?${query.toString()}`],
1073
+ username,
1074
+ });
1075
+ return toXurlMentionsResponse(payload);
504
1076
  });
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
1077
  }
528
1078
 
529
- export async function listLikedTweetsViaXurl(options: {
1079
+ export function listLikedTweetsViaXurlEffect(options: {
530
1080
  maxResults: number;
531
1081
  username?: string;
532
1082
  userId?: string;
533
1083
  paginationToken?: string;
534
- }): Promise<XurlMentionsResponse> {
535
- return listTimelineCollectionViaXurl({
1084
+ }) {
1085
+ return listTimelineCollectionViaXurlEffect({
536
1086
  ...options,
537
1087
  collection: "liked_tweets",
538
1088
  });
539
1089
  }
540
1090
 
541
- export async function listBookmarkedTweetsViaXurl(options: {
1091
+ export function listLikedTweetsViaXurl(options: {
542
1092
  maxResults: number;
543
1093
  username?: string;
544
1094
  userId?: string;
545
- isPaginatedWalk?: boolean;
546
1095
  paginationToken?: string;
547
1096
  }): Promise<XurlMentionsResponse> {
548
- return listTimelineCollectionViaXurl({
1097
+ return runEffectPromise(listLikedTweetsViaXurlEffect(options));
1098
+ }
1099
+
1100
+ export function listBookmarkedTweetsViaXurlEffect(options: {
1101
+ maxResults: number;
1102
+ username?: string;
1103
+ userId?: string;
1104
+ isPaginatedWalk?: boolean;
1105
+ paginationToken?: string;
1106
+ }) {
1107
+ return listTimelineCollectionViaXurlEffect({
549
1108
  ...options,
550
1109
  collection: "bookmarks",
551
1110
  });
552
1111
  }
553
1112
 
554
- export async function listFollowUsersViaXurl({
1113
+ export function listBookmarkedTweetsViaXurl(options: {
1114
+ maxResults: number;
1115
+ username?: string;
1116
+ userId?: string;
1117
+ isPaginatedWalk?: boolean;
1118
+ paginationToken?: string;
1119
+ }): Promise<XurlMentionsResponse> {
1120
+ return runEffectPromise(listBookmarkedTweetsViaXurlEffect(options));
1121
+ }
1122
+
1123
+ export function listDirectMessageEventsViaXurlEffect({
1124
+ maxResults,
1125
+ username,
1126
+ paginationToken,
1127
+ }: {
1128
+ maxResults: number;
1129
+ username?: string;
1130
+ paginationToken?: string;
1131
+ }): Effect.Effect<XurlDmEventsResponse, Error> {
1132
+ const query = new URLSearchParams({
1133
+ max_results: String(maxResults),
1134
+ event_types: "MessageCreate",
1135
+ "dm_event.fields": DM_EVENT_FIELDS,
1136
+ expansions: "sender_id,participant_ids",
1137
+ "user.fields": RICH_USER_FIELDS,
1138
+ });
1139
+ if (paginationToken) {
1140
+ query.set("pagination_token", paginationToken);
1141
+ }
1142
+
1143
+ return runOAuth2JsonCommandEffect({
1144
+ args: [`/2/dm_events?${query.toString()}`],
1145
+ username,
1146
+ }).pipe(
1147
+ Effect.map((payload) => ({
1148
+ data: Array.isArray(payload.data)
1149
+ ? (payload.data as XurlDmEventsResponse["data"])
1150
+ : [],
1151
+ includes:
1152
+ payload.includes && typeof payload.includes === "object"
1153
+ ? (payload.includes as XurlDmEventsResponse["includes"])
1154
+ : undefined,
1155
+ meta:
1156
+ payload.meta && typeof payload.meta === "object"
1157
+ ? (payload.meta as Record<string, unknown>)
1158
+ : undefined,
1159
+ })),
1160
+ );
1161
+ }
1162
+
1163
+ export function listDirectMessageEventsViaXurl(options: {
1164
+ maxResults: number;
1165
+ username?: string;
1166
+ paginationToken?: string;
1167
+ }): Promise<XurlDmEventsResponse> {
1168
+ return runEffectPromise(listDirectMessageEventsViaXurlEffect(options));
1169
+ }
1170
+
1171
+ export function listFollowUsersViaXurlEffect({
555
1172
  direction,
556
1173
  maxResults,
557
1174
  username,
@@ -563,50 +1180,45 @@ export async function listFollowUsersViaXurl({
563
1180
  username?: string;
564
1181
  userId?: string;
565
1182
  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);
1183
+ }): Effect.Effect<XurlFollowUsersResponse, Error> {
1184
+ return Effect.gen(function* () {
1185
+ const resolvedUserId = yield* resolveUserIdEffect({ username, userId });
1186
+ const query = new URLSearchParams({
1187
+ max_results: String(maxResults),
1188
+ "user.fields":
1189
+ "id,username,name,description,verified,protected,public_metrics,profile_image_url,created_at",
1190
+ });
1191
+ if (paginationToken) {
1192
+ query.set("pagination_token", paginationToken);
581
1193
  }
582
- }
583
1194
 
584
- const query = new URLSearchParams({
585
- max_results: String(maxResults),
586
- "user.fields":
587
- "id,username,name,description,verified,protected,public_metrics,profile_image_url,created_at",
1195
+ const payload = yield* runOAuth2JsonCommandEffect({
1196
+ args: [`/2/users/${resolvedUserId}/${direction}?${query.toString()}`],
1197
+ username,
1198
+ });
1199
+ return {
1200
+ data: Array.isArray(payload.data)
1201
+ ? (payload.data as XurlMentionUser[])
1202
+ : [],
1203
+ meta:
1204
+ payload.meta && typeof payload.meta === "object"
1205
+ ? (payload.meta as Record<string, unknown>)
1206
+ : undefined,
1207
+ };
588
1208
  });
589
- if (paginationToken) {
590
- query.set("pagination_token", paginationToken);
591
- }
1209
+ }
592
1210
 
593
- const payload = await runJsonCommand([
594
- "--auth",
595
- "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
- };
1211
+ export function listFollowUsersViaXurl(options: {
1212
+ direction: FollowDirection;
1213
+ maxResults: number;
1214
+ username?: string;
1215
+ userId?: string;
1216
+ paginationToken?: string;
1217
+ }): Promise<XurlFollowUsersResponse> {
1218
+ return runEffectPromise(listFollowUsersViaXurlEffect(options));
607
1219
  }
608
1220
 
609
- export async function listBlockedUsers(
1221
+ export function listBlockedUsersEffect(
610
1222
  userId: string,
611
1223
  paginationToken?: string,
612
1224
  ) {
@@ -619,25 +1231,30 @@ export async function listBlockedUsers(
619
1231
  query.set("pagination_token", paginationToken);
620
1232
  }
621
1233
 
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;
1234
+ return runJsonCommandEffect([`/2/users/${userId}/blocking?${query}`]).pipe(
1235
+ Effect.map((payload) => {
1236
+ const data = Array.isArray(payload.data)
1237
+ ? (payload.data as XurlMentionUser[])
1238
+ : [];
1239
+ const meta =
1240
+ payload.meta && typeof payload.meta === "object"
1241
+ ? (payload.meta as Record<string, unknown>)
1242
+ : null;
632
1243
 
633
- return {
634
- items: data,
635
- nextToken:
636
- typeof meta?.next_token === "string" ? String(meta.next_token) : null,
637
- };
1244
+ return {
1245
+ items: data,
1246
+ nextToken:
1247
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
1248
+ };
1249
+ }),
1250
+ );
1251
+ }
1252
+
1253
+ export function listBlockedUsers(userId: string, paginationToken?: string) {
1254
+ return runEffectPromise(listBlockedUsersEffect(userId, paginationToken));
638
1255
  }
639
1256
 
640
- export async function listUserTweets(
1257
+ export function listUserTweetsEffect(
641
1258
  userId: string,
642
1259
  {
643
1260
  maxResults,
@@ -650,6 +1267,10 @@ export async function listUserTweets(
650
1267
  userFields,
651
1268
  mediaFields,
652
1269
  auth,
1270
+ username,
1271
+ signal,
1272
+ onAttempt,
1273
+ useConfiguredCandidate,
653
1274
  }: {
654
1275
  maxResults: number;
655
1276
  paginationToken?: string;
@@ -661,8 +1282,12 @@ export async function listUserTweets(
661
1282
  userFields?: string[];
662
1283
  mediaFields?: string[];
663
1284
  auth?: "oauth2";
1285
+ username?: string;
1286
+ signal?: AbortSignal;
1287
+ onAttempt?: JsonCommandOptions["onAttempt"];
1288
+ useConfiguredCandidate?: boolean;
664
1289
  },
665
- ): Promise<XurlUserTweetsResponse> {
1290
+ ): Effect.Effect<XurlUserTweetsResponse, Error> {
666
1291
  const query = new URLSearchParams({
667
1292
  max_results: String(maxResults),
668
1293
  expansions: MEDIA_EXPANSION,
@@ -694,34 +1319,84 @@ export async function listUserTweets(
694
1319
  }
695
1320
 
696
1321
  const endpoint = `/2/users/${userId}/tweets?${query}`;
697
- const payload = await runJsonCommand(
698
- auth === "oauth2" ? ["--auth", "oauth2", endpoint] : [endpoint],
1322
+ const command =
1323
+ auth === "oauth2"
1324
+ ? runOAuth2JsonCommandEffect({
1325
+ args: [endpoint],
1326
+ username,
1327
+ options: { signal, onAttempt },
1328
+ useConfiguredCandidate,
1329
+ })
1330
+ : runJsonCommandEffect([endpoint], { signal, onAttempt });
1331
+ return command.pipe(
1332
+ Effect.map((payload) => {
1333
+ const data = Array.isArray(payload.data)
1334
+ ? (payload.data as XurlUserTweet[])
1335
+ : [];
1336
+ const meta =
1337
+ payload.meta && typeof payload.meta === "object"
1338
+ ? (payload.meta as Record<string, unknown>)
1339
+ : null;
1340
+ const includes =
1341
+ payload.includes && typeof payload.includes === "object"
1342
+ ? (payload.includes as XurlUserTweetsResponse["includes"])
1343
+ : undefined;
1344
+
1345
+ return {
1346
+ items: data,
1347
+ nextToken:
1348
+ typeof meta?.next_token === "string" ? String(meta.next_token) : null,
1349
+ ...(includes ? { includes } : {}),
1350
+ };
1351
+ }),
699
1352
  );
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;
1353
+ }
1354
+
1355
+ export function listUserTweets(
1356
+ userId: string,
1357
+ options: {
1358
+ maxResults: number;
1359
+ paginationToken?: string;
1360
+ excludeRetweets?: boolean;
1361
+ sinceId?: string;
1362
+ untilId?: string;
1363
+ tweetFields?: string[];
1364
+ expansions?: string[];
1365
+ userFields?: string[];
1366
+ mediaFields?: string[];
1367
+ auth?: "oauth2";
1368
+ username?: string;
1369
+ signal?: AbortSignal;
1370
+ onAttempt?: JsonCommandOptions["onAttempt"];
1371
+ useConfiguredCandidate?: boolean;
1372
+ },
1373
+ ): Promise<XurlUserTweetsResponse> {
1374
+ return runEffectPromise(listUserTweetsEffect(userId, options));
1375
+ }
711
1376
 
1377
+ function toXurlTweetsResponse(
1378
+ payload: Record<string, unknown>,
1379
+ ): XurlTweetsResponse {
712
1380
  return {
713
- items: data,
714
- nextToken:
715
- typeof meta?.next_token === "string" ? String(meta.next_token) : null,
716
- ...(includes ? { includes } : {}),
1381
+ data: Array.isArray(payload.data)
1382
+ ? (payload.data as XurlTweetsResponse["data"])
1383
+ : [],
1384
+ includes:
1385
+ payload.includes && typeof payload.includes === "object"
1386
+ ? (payload.includes as XurlTweetsResponse["includes"])
1387
+ : undefined,
1388
+ meta:
1389
+ payload.meta && typeof payload.meta === "object"
1390
+ ? (payload.meta as XurlTweetsResponse["meta"])
1391
+ : undefined,
717
1392
  };
718
1393
  }
719
1394
 
720
- export async function lookupTweetsByIds(
1395
+ export function lookupTweetsByIdsEffect(
721
1396
  ids: string[],
722
- ): Promise<XurlTweetsResponse> {
1397
+ ): Effect.Effect<XurlTweetsResponse, Error> {
723
1398
  if (ids.length === 0) {
724
- return { data: [] };
1399
+ return Effect.succeed({ data: [] });
725
1400
  }
726
1401
 
727
1402
  const query = new URLSearchParams({
@@ -734,34 +1409,35 @@ export async function lookupTweetsByIds(
734
1409
  "description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
735
1410
  });
736
1411
 
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
- };
1412
+ return runJsonCommandEffect([`/2/tweets?${query.toString()}`]).pipe(
1413
+ Effect.map(toXurlTweetsResponse),
1414
+ );
1415
+ }
1416
+
1417
+ export function lookupTweetsByIds(ids: string[]): Promise<XurlTweetsResponse> {
1418
+ return runEffectPromise(lookupTweetsByIdsEffect(ids));
751
1419
  }
752
1420
 
753
- export async function searchRecentByConversationId(
1421
+ export function searchRecentByConversationIdEffect(
754
1422
  conversationId: string,
755
1423
  {
756
1424
  maxResults,
757
1425
  paginationToken,
758
1426
  timeoutMs,
1427
+ auth,
1428
+ username,
1429
+ signal,
1430
+ onAttempt,
759
1431
  }: {
760
1432
  maxResults: number;
761
1433
  paginationToken?: string;
762
1434
  timeoutMs?: number;
1435
+ auth?: "oauth2";
1436
+ username?: string;
1437
+ signal?: AbortSignal;
1438
+ onAttempt?: JsonCommandOptions["onAttempt"];
763
1439
  },
764
- ): Promise<XurlTweetsResponse> {
1440
+ ): Effect.Effect<XurlTweetsResponse, Error> {
765
1441
  const query = new URLSearchParams({
766
1442
  query: `conversation_id:${conversationId}`,
767
1443
  max_results: String(maxResults),
@@ -774,29 +1450,98 @@ export async function searchRecentByConversationId(
774
1450
  query.set("pagination_token", paginationToken);
775
1451
  }
776
1452
 
777
- const payload = await runJsonCommand(
778
- [`/2/tweets/search/recent?${query.toString()}`],
779
- { timeoutMs },
1453
+ const args = [`/2/tweets/search/recent?${query.toString()}`];
1454
+ const command =
1455
+ auth === "oauth2"
1456
+ ? runOAuth2JsonCommandEffect({
1457
+ args,
1458
+ username,
1459
+ options: { timeoutMs, signal, onAttempt },
1460
+ useConfiguredCandidate: false,
1461
+ })
1462
+ : runJsonCommandEffect(args, { timeoutMs, signal, onAttempt });
1463
+ return command.pipe(Effect.map(toXurlTweetsResponse));
1464
+ }
1465
+
1466
+ export function searchRecentByConversationId(
1467
+ conversationId: string,
1468
+ options: {
1469
+ maxResults: number;
1470
+ paginationToken?: string;
1471
+ timeoutMs?: number;
1472
+ auth?: "oauth2";
1473
+ username?: string;
1474
+ signal?: AbortSignal;
1475
+ onAttempt?: JsonCommandOptions["onAttempt"];
1476
+ },
1477
+ ): Promise<XurlTweetsResponse> {
1478
+ return runEffectPromise(
1479
+ searchRecentByConversationIdEffect(conversationId, options),
780
1480
  );
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
1481
  }
795
1482
 
796
- export async function getTweetById(
1483
+ export function searchRecentTweetsEffect(
1484
+ searchQuery: string,
1485
+ {
1486
+ maxResults,
1487
+ paginationToken,
1488
+ startTime,
1489
+ endTime,
1490
+ username,
1491
+ timeoutMs,
1492
+ }: {
1493
+ maxResults: number;
1494
+ paginationToken?: string;
1495
+ startTime?: string;
1496
+ endTime?: string;
1497
+ username?: string;
1498
+ timeoutMs?: number;
1499
+ },
1500
+ ): Effect.Effect<XurlTweetsResponse, Error> {
1501
+ const query = new URLSearchParams({
1502
+ query: searchQuery,
1503
+ max_results: String(maxResults),
1504
+ expansions: AUTHOR_MEDIA_EXPANSIONS,
1505
+ "tweet.fields": THREAD_TWEET_FIELDS,
1506
+ "media.fields": MEDIA_FIELDS,
1507
+ "user.fields": RICH_USER_FIELDS,
1508
+ });
1509
+ if (paginationToken) {
1510
+ query.set("pagination_token", paginationToken);
1511
+ }
1512
+ if (startTime) {
1513
+ query.set("start_time", startTime);
1514
+ }
1515
+ if (endTime) {
1516
+ query.set("end_time", endTime);
1517
+ }
1518
+
1519
+ return runOAuth2JsonCommandEffect({
1520
+ args: [`/2/tweets/search/recent?${query.toString()}`],
1521
+ username,
1522
+ options: { timeoutMs },
1523
+ useConfiguredCandidate: false,
1524
+ }).pipe(Effect.map(toXurlTweetsResponse));
1525
+ }
1526
+
1527
+ export function searchRecentTweets(
1528
+ searchQuery: string,
1529
+ options: {
1530
+ maxResults: number;
1531
+ paginationToken?: string;
1532
+ startTime?: string;
1533
+ endTime?: string;
1534
+ username?: string;
1535
+ timeoutMs?: number;
1536
+ },
1537
+ ): Promise<XurlTweetsResponse> {
1538
+ return runEffectPromise(searchRecentTweetsEffect(searchQuery, options));
1539
+ }
1540
+
1541
+ export function getTweetByIdEffect(
797
1542
  id: string,
798
1543
  { timeoutMs }: { timeoutMs?: number } = {},
799
- ): Promise<XurlTweetsResponse> {
1544
+ ): Effect.Effect<XurlTweetsResponse, Error> {
800
1545
  const query = new URLSearchParams({
801
1546
  expansions: AUTHOR_MEDIA_EXPANSIONS,
802
1547
  "tweet.fields": THREAD_TWEET_FIELDS,
@@ -804,55 +1549,74 @@ export async function getTweetById(
804
1549
  "user.fields": RICH_USER_FIELDS,
805
1550
  });
806
1551
 
807
- const payload = await runJsonCommand(
808
- [`/2/tweets/${id}?${query.toString()}`],
809
- {
810
- timeoutMs,
811
- },
1552
+ return runJsonCommandEffect([`/2/tweets/${id}?${query.toString()}`], {
1553
+ timeoutMs,
1554
+ }).pipe(
1555
+ Effect.map((payload) => {
1556
+ const data =
1557
+ payload.data &&
1558
+ typeof payload.data === "object" &&
1559
+ !Array.isArray(payload.data)
1560
+ ? [payload.data as XurlTweetsResponse["data"][number]]
1561
+ : Array.isArray(payload.data)
1562
+ ? (payload.data as XurlTweetsResponse["data"])
1563
+ : [];
1564
+
1565
+ return {
1566
+ data,
1567
+ includes:
1568
+ payload.includes && typeof payload.includes === "object"
1569
+ ? (payload.includes as XurlTweetsResponse["includes"])
1570
+ : undefined,
1571
+ meta:
1572
+ payload.meta && typeof payload.meta === "object"
1573
+ ? (payload.meta as XurlTweetsResponse["meta"])
1574
+ : undefined,
1575
+ };
1576
+ }),
812
1577
  );
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
- : [];
1578
+ }
821
1579
 
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
- };
1580
+ export function getTweetById(
1581
+ id: string,
1582
+ options: { timeoutMs?: number } = {},
1583
+ ): Promise<XurlTweetsResponse> {
1584
+ return runEffectPromise(getTweetByIdEffect(id, options));
1585
+ }
1586
+
1587
+ export function postViaXurlEffect(text: string) {
1588
+ return runShortcutEffect(["post", text]);
1589
+ }
1590
+
1591
+ export function postViaXurl(text: string) {
1592
+ return runEffectPromise(postViaXurlEffect(text));
833
1593
  }
834
1594
 
835
- export async function postViaXurl(text: string) {
836
- return runShortcut(["post", text]);
1595
+ export function replyViaXurlEffect(tweetId: string, text: string) {
1596
+ return runShortcutEffect(["reply", tweetId, text]);
837
1597
  }
838
1598
 
839
- export async function replyViaXurl(tweetId: string, text: string) {
840
- return runShortcut(["reply", tweetId, text]);
1599
+ export function replyViaXurl(tweetId: string, text: string) {
1600
+ return runEffectPromise(replyViaXurlEffect(tweetId, text));
841
1601
  }
842
1602
 
843
- export async function dmViaXurl(handle: string, text: string) {
844
- return runShortcut([
1603
+ export function dmViaXurlEffect(handle: string, text: string) {
1604
+ return runShortcutEffect([
845
1605
  "dm",
846
1606
  handle.startsWith("@") ? handle : `@${handle}`,
847
1607
  text,
848
1608
  ]);
849
1609
  }
850
1610
 
851
- export async function blockUserViaXurl(
1611
+ export function dmViaXurl(handle: string, text: string) {
1612
+ return runEffectPromise(dmViaXurlEffect(handle, text));
1613
+ }
1614
+
1615
+ export function blockUserViaXurlEffect(
852
1616
  sourceUserId: string,
853
1617
  targetUserId: string,
854
1618
  ) {
855
- return runMutationCommand([
1619
+ return runMutationCommandEffect([
856
1620
  "-X",
857
1621
  "POST",
858
1622
  `/2/users/${sourceUserId}/blocking`,
@@ -861,22 +1625,30 @@ export async function blockUserViaXurl(
861
1625
  ]);
862
1626
  }
863
1627
 
864
- export async function unblockUserViaXurl(
1628
+ export function blockUserViaXurl(sourceUserId: string, targetUserId: string) {
1629
+ return runEffectPromise(blockUserViaXurlEffect(sourceUserId, targetUserId));
1630
+ }
1631
+
1632
+ export function unblockUserViaXurlEffect(
865
1633
  sourceUserId: string,
866
1634
  targetUserId: string,
867
1635
  ) {
868
- return runMutationCommand([
1636
+ return runMutationCommandEffect([
869
1637
  "-X",
870
1638
  "DELETE",
871
1639
  `/2/users/${sourceUserId}/blocking/${targetUserId}`,
872
1640
  ]);
873
1641
  }
874
1642
 
875
- export async function muteUserViaXurl(
1643
+ export function unblockUserViaXurl(sourceUserId: string, targetUserId: string) {
1644
+ return runEffectPromise(unblockUserViaXurlEffect(sourceUserId, targetUserId));
1645
+ }
1646
+
1647
+ export function muteUserViaXurlEffect(
876
1648
  sourceUserId: string,
877
1649
  targetUserId: string,
878
1650
  ) {
879
- return runMutationCommand([
1651
+ return runMutationCommandEffect([
880
1652
  "-X",
881
1653
  "POST",
882
1654
  `/2/users/${sourceUserId}/muting`,
@@ -885,13 +1657,21 @@ export async function muteUserViaXurl(
885
1657
  ]);
886
1658
  }
887
1659
 
888
- export async function unmuteUserViaXurl(
1660
+ export function muteUserViaXurl(sourceUserId: string, targetUserId: string) {
1661
+ return runEffectPromise(muteUserViaXurlEffect(sourceUserId, targetUserId));
1662
+ }
1663
+
1664
+ export function unmuteUserViaXurlEffect(
889
1665
  sourceUserId: string,
890
1666
  targetUserId: string,
891
1667
  ) {
892
- return runMutationCommand([
1668
+ return runMutationCommandEffect([
893
1669
  "-X",
894
1670
  "DELETE",
895
1671
  `/2/users/${sourceUserId}/muting/${targetUserId}`,
896
1672
  ]);
897
1673
  }
1674
+
1675
+ export function unmuteUserViaXurl(sourceUserId: string, targetUserId: string) {
1676
+ return runEffectPromise(unmuteUserViaXurlEffect(sourceUserId, targetUserId));
1677
+ }