birdclaw 0.6.0 → 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.
- package/CHANGELOG.md +48 -0
- package/README.md +25 -0
- package/package.json +6 -1
- package/src/cli.ts +438 -26
- package/src/components/AppNav.tsx +10 -0
- package/src/components/MarkdownViewer.tsx +438 -72
- package/src/components/ProfileAnalysisStream.tsx +428 -0
- package/src/components/ProfilePreview.tsx +120 -9
- package/src/components/SavedTimelineView.tsx +30 -8
- package/src/components/SyncNowButton.tsx +5 -2
- package/src/components/TimelineCard.tsx +20 -4
- package/src/components/TimelineRouteFrame.tsx +16 -0
- package/src/components/TweetRichText.tsx +36 -12
- package/src/components/useTimelineRouteData.ts +74 -6
- package/src/lib/account-sync-job.ts +15 -3
- package/src/lib/archive-finder.ts +1 -1
- package/src/lib/archive-import.ts +245 -7
- package/src/lib/authored-live.ts +1 -0
- package/src/lib/avatar-cache.ts +50 -0
- package/src/lib/backup.ts +4 -3
- package/src/lib/bird.ts +33 -0
- package/src/lib/config.ts +35 -2
- package/src/lib/data-sources.ts +219 -0
- package/src/lib/db.ts +62 -1
- package/src/lib/geocoding.ts +296 -0
- package/src/lib/location.ts +137 -0
- package/src/lib/mention-threads-live.ts +94 -1
- package/src/lib/mentions-live.ts +187 -40
- package/src/lib/network-map.ts +382 -0
- package/src/lib/period-digest.ts +377 -13
- package/src/lib/profile-analysis.ts +1272 -0
- package/src/lib/profile-bio-entities.ts +1 -1
- package/src/lib/queries.ts +14 -4
- package/src/lib/search-discussion.ts +1016 -0
- package/src/lib/timeline-live.ts +272 -19
- package/src/lib/tweet-account-edges.ts +2 -0
- package/src/lib/tweet-render.ts +141 -1
- package/src/lib/tweet-search-live.ts +565 -0
- package/src/lib/types.ts +37 -2
- package/src/lib/ui.ts +1 -1
- package/src/lib/web-sync.ts +7 -2
- package/src/lib/xurl-rate-limits.ts +267 -0
- package/src/lib/xurl.ts +551 -41
- package/src/routeTree.gen.ts +231 -0
- package/src/routes/__root.tsx +5 -6
- package/src/routes/api/data-sources.tsx +24 -0
- package/src/routes/api/network-map.tsx +55 -0
- package/src/routes/api/period-digest.tsx +11 -1
- package/src/routes/api/profile-analysis.tsx +152 -0
- package/src/routes/api/query.tsx +1 -0
- package/src/routes/api/search-discussion.tsx +169 -0
- package/src/routes/api/xurl-rate-limits.tsx +24 -0
- package/src/routes/data-sources.tsx +255 -0
- package/src/routes/discuss.tsx +419 -0
- package/src/routes/network-map.tsx +1035 -0
- package/src/routes/profile-analyze.tsx +112 -0
- package/src/routes/profiles.$handle.tsx +228 -0
- package/src/routes/rate-limits.tsx +309 -0
- package/src/routes/today.tsx +22 -8
- package/src/styles.css +22 -0
package/src/lib/xurl.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Effect } from "effect";
|
|
|
4
4
|
import { runEffectPromise } from "./effect-runtime";
|
|
5
5
|
import type {
|
|
6
6
|
FollowDirection,
|
|
7
|
+
LiveDataSourceAccount,
|
|
7
8
|
TransportStatus,
|
|
8
9
|
XurlDmEventsResponse,
|
|
9
10
|
XurlFollowUsersResponse,
|
|
@@ -36,6 +37,18 @@ type TimelineCollectionEndpoint = "liked_tweets" | "bookmarks";
|
|
|
36
37
|
type JsonCommandOptions = {
|
|
37
38
|
timeoutMs?: number;
|
|
38
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;
|
|
39
52
|
};
|
|
40
53
|
|
|
41
54
|
let transportStatusCache:
|
|
@@ -52,6 +65,10 @@ let authenticatedUserCache:
|
|
|
52
65
|
value?: Record<string, unknown> | null;
|
|
53
66
|
}
|
|
54
67
|
| undefined;
|
|
68
|
+
const oauth2CandidateCache = new Map<
|
|
69
|
+
string,
|
|
70
|
+
{ expiresAt: number; value: OAuth2UsernameCandidate }
|
|
71
|
+
>();
|
|
55
72
|
|
|
56
73
|
function liveWritesDisabled() {
|
|
57
74
|
return process.env.BIRDCLAW_DISABLE_LIVE_WRITES === "1";
|
|
@@ -139,6 +156,17 @@ function getRetryDelayMs(error: unknown, attempt: number) {
|
|
|
139
156
|
return Math.min(baseDelay * 2 ** attempt, 30_000);
|
|
140
157
|
}
|
|
141
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
|
+
|
|
142
170
|
function capTimelineCollectionMaxResults(
|
|
143
171
|
collection: TimelineCollectionEndpoint,
|
|
144
172
|
maxResults: number,
|
|
@@ -155,6 +183,7 @@ export function resetTransportStatusCache() {
|
|
|
155
183
|
|
|
156
184
|
export function resetAuthenticatedUserCache() {
|
|
157
185
|
authenticatedUserCache = undefined;
|
|
186
|
+
oauth2CandidateCache.clear();
|
|
158
187
|
}
|
|
159
188
|
|
|
160
189
|
function hasXurlEffect() {
|
|
@@ -295,9 +324,66 @@ function runShortcutEffect(args: string[]) {
|
|
|
295
324
|
function execXurlJsonEffect(
|
|
296
325
|
args: string[],
|
|
297
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
|
+
}
|
|
370
|
+
|
|
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");
|
|
376
|
+
}
|
|
377
|
+
return timeoutMs;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function execXurlTextEffect(
|
|
381
|
+
args: string[],
|
|
382
|
+
deadlineMs?: number,
|
|
298
383
|
): Effect.Effect<{ stdout: string; stderr: string }, Error> {
|
|
299
384
|
return Effect.tryPromise({
|
|
300
385
|
try: () => {
|
|
386
|
+
const timeoutMs = getRemainingTimeoutMs(deadlineMs);
|
|
301
387
|
const controller =
|
|
302
388
|
typeof timeoutMs === "number" &&
|
|
303
389
|
Number.isFinite(timeoutMs) &&
|
|
@@ -346,13 +432,27 @@ function runJsonCommandEffect(
|
|
|
346
432
|
const timeoutMs = deadlineMs
|
|
347
433
|
? Math.max(0, deadlineMs - Date.now())
|
|
348
434
|
: undefined;
|
|
349
|
-
return yield* execXurlJsonEffect(args, timeoutMs).pipe(
|
|
435
|
+
return yield* execXurlJsonEffect(args, timeoutMs, options.signal).pipe(
|
|
350
436
|
Effect.flatMap(({ stdout }) => parseJsonPayloadEffect(stdout, args)),
|
|
437
|
+
Effect.tap(() =>
|
|
438
|
+
Effect.sync(() =>
|
|
439
|
+
emitJsonCommandAttempt(options, { args, attempt, status: "ok" }),
|
|
440
|
+
),
|
|
441
|
+
),
|
|
351
442
|
Effect.catchAll((error) => {
|
|
352
443
|
const retryDelayMs = getRetryDelayMs(error, attempt);
|
|
444
|
+
emitJsonCommandAttempt(options, {
|
|
445
|
+
args,
|
|
446
|
+
attempt,
|
|
447
|
+
status: retryDelayMs === null ? "error" : "rate_limited",
|
|
448
|
+
error,
|
|
449
|
+
});
|
|
353
450
|
if (retryDelayMs === null || attempt >= JSON_RETRY_LIMIT - 1) {
|
|
354
451
|
return Effect.fail(formatXurlCommandError(error, args));
|
|
355
452
|
}
|
|
453
|
+
if (options.signal?.aborted) {
|
|
454
|
+
return Effect.fail(formatXurlCommandError(error, args));
|
|
455
|
+
}
|
|
356
456
|
const remainingMs = deadlineMs
|
|
357
457
|
? Math.max(0, deadlineMs - Date.now())
|
|
358
458
|
: undefined;
|
|
@@ -370,6 +470,255 @@ function runJsonCommandEffect(
|
|
|
370
470
|
});
|
|
371
471
|
}
|
|
372
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
|
|
483
|
+
: undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
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;
|
|
504
|
+
}
|
|
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 }
|
|
628
|
+
: undefined;
|
|
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
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (deadlineMs !== undefined && Date.now() >= deadlineMs) {
|
|
677
|
+
return yield* Effect.fail(new Error("xurl OAuth2 fallback timed out"));
|
|
678
|
+
}
|
|
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
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
373
722
|
function runMutationCommandEffect(args: string[]) {
|
|
374
723
|
return Effect.gen(function* () {
|
|
375
724
|
if (liveWritesDisabled()) {
|
|
@@ -418,7 +767,15 @@ export function lookupUsersByIds(ids: string[]) {
|
|
|
418
767
|
return runEffectPromise(lookupUsersByIdsEffect(ids));
|
|
419
768
|
}
|
|
420
769
|
|
|
421
|
-
export function lookupUsersByHandlesEffect(
|
|
770
|
+
export function lookupUsersByHandlesEffect(
|
|
771
|
+
handles: string[],
|
|
772
|
+
options: {
|
|
773
|
+
auth?: "oauth2";
|
|
774
|
+
username?: string;
|
|
775
|
+
signal?: AbortSignal;
|
|
776
|
+
useConfiguredCandidate?: boolean;
|
|
777
|
+
} = {},
|
|
778
|
+
) {
|
|
422
779
|
if (handles.length === 0) {
|
|
423
780
|
return Effect.succeed([]);
|
|
424
781
|
}
|
|
@@ -428,15 +785,33 @@ export function lookupUsersByHandlesEffect(handles: string[]) {
|
|
|
428
785
|
"user.fields":
|
|
429
786
|
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
430
787
|
});
|
|
431
|
-
|
|
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(
|
|
432
799
|
Effect.map((payload) =>
|
|
433
800
|
Array.isArray(payload.data) ? (payload.data as XurlMentionUser[]) : [],
|
|
434
801
|
),
|
|
435
802
|
);
|
|
436
803
|
}
|
|
437
804
|
|
|
438
|
-
export function lookupUsersByHandles(
|
|
439
|
-
|
|
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));
|
|
440
815
|
}
|
|
441
816
|
|
|
442
817
|
function authenticatedUserFromPayload(payload: Record<string, unknown>) {
|
|
@@ -452,18 +827,11 @@ export function lookupAuthenticatedUserFreshEffect() {
|
|
|
452
827
|
);
|
|
453
828
|
}
|
|
454
829
|
|
|
455
|
-
function oauth2UsernameArgs(username?: string) {
|
|
456
|
-
const normalized = username?.trim().replace(/^@/, "");
|
|
457
|
-
return normalized ? ["--username", normalized] : [];
|
|
458
|
-
}
|
|
459
|
-
|
|
460
830
|
export function lookupAuthenticatedOAuth2UserEffect(username?: string) {
|
|
461
|
-
return
|
|
462
|
-
"
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
"whoami",
|
|
466
|
-
]).pipe(Effect.map(authenticatedUserFromPayload));
|
|
831
|
+
return runOAuth2JsonCommandEffect({
|
|
832
|
+
args: ["whoami"],
|
|
833
|
+
username,
|
|
834
|
+
}).pipe(Effect.map(authenticatedUserFromPayload));
|
|
467
835
|
}
|
|
468
836
|
|
|
469
837
|
export function lookupAuthenticatedUserEffect() {
|
|
@@ -580,9 +948,10 @@ export function listMentionsViaXurlEffect({
|
|
|
580
948
|
query.set("start_time", startTime);
|
|
581
949
|
}
|
|
582
950
|
|
|
583
|
-
const payload = yield*
|
|
584
|
-
`/2/users/${resolvedUserId}/mentions?${query.toString()}
|
|
585
|
-
|
|
951
|
+
const payload = yield* runOAuth2JsonCommandEffect({
|
|
952
|
+
args: [`/2/users/${resolvedUserId}/mentions?${query.toString()}`],
|
|
953
|
+
username,
|
|
954
|
+
});
|
|
586
955
|
return toXurlMentionsResponse(payload);
|
|
587
956
|
});
|
|
588
957
|
}
|
|
@@ -598,6 +967,54 @@ export function listMentionsViaXurl(options: {
|
|
|
598
967
|
return runEffectPromise(listMentionsViaXurlEffect(options));
|
|
599
968
|
}
|
|
600
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);
|
|
995
|
+
}
|
|
996
|
+
|
|
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);
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
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
|
+
|
|
601
1018
|
function toXurlMentionsResponse(
|
|
602
1019
|
payload: Record<string, unknown>,
|
|
603
1020
|
): XurlMentionsResponse {
|
|
@@ -651,11 +1068,10 @@ function listTimelineCollectionViaXurlEffect({
|
|
|
651
1068
|
query.set("pagination_token", paginationToken);
|
|
652
1069
|
}
|
|
653
1070
|
|
|
654
|
-
const payload = yield*
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
]);
|
|
1071
|
+
const payload = yield* runOAuth2JsonCommandEffect({
|
|
1072
|
+
args: [`/2/users/${resolvedUserId}/${collection}?${query.toString()}`],
|
|
1073
|
+
username,
|
|
1074
|
+
});
|
|
659
1075
|
return toXurlMentionsResponse(payload);
|
|
660
1076
|
});
|
|
661
1077
|
}
|
|
@@ -724,12 +1140,10 @@ export function listDirectMessageEventsViaXurlEffect({
|
|
|
724
1140
|
query.set("pagination_token", paginationToken);
|
|
725
1141
|
}
|
|
726
1142
|
|
|
727
|
-
return
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
`/2/dm_events?${query.toString()}`,
|
|
732
|
-
]).pipe(
|
|
1143
|
+
return runOAuth2JsonCommandEffect({
|
|
1144
|
+
args: [`/2/dm_events?${query.toString()}`],
|
|
1145
|
+
username,
|
|
1146
|
+
}).pipe(
|
|
733
1147
|
Effect.map((payload) => ({
|
|
734
1148
|
data: Array.isArray(payload.data)
|
|
735
1149
|
? (payload.data as XurlDmEventsResponse["data"])
|
|
@@ -778,11 +1192,10 @@ export function listFollowUsersViaXurlEffect({
|
|
|
778
1192
|
query.set("pagination_token", paginationToken);
|
|
779
1193
|
}
|
|
780
1194
|
|
|
781
|
-
const payload = yield*
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
]);
|
|
1195
|
+
const payload = yield* runOAuth2JsonCommandEffect({
|
|
1196
|
+
args: [`/2/users/${resolvedUserId}/${direction}?${query.toString()}`],
|
|
1197
|
+
username,
|
|
1198
|
+
});
|
|
786
1199
|
return {
|
|
787
1200
|
data: Array.isArray(payload.data)
|
|
788
1201
|
? (payload.data as XurlMentionUser[])
|
|
@@ -854,6 +1267,10 @@ export function listUserTweetsEffect(
|
|
|
854
1267
|
userFields,
|
|
855
1268
|
mediaFields,
|
|
856
1269
|
auth,
|
|
1270
|
+
username,
|
|
1271
|
+
signal,
|
|
1272
|
+
onAttempt,
|
|
1273
|
+
useConfiguredCandidate,
|
|
857
1274
|
}: {
|
|
858
1275
|
maxResults: number;
|
|
859
1276
|
paginationToken?: string;
|
|
@@ -865,6 +1282,10 @@ export function listUserTweetsEffect(
|
|
|
865
1282
|
userFields?: string[];
|
|
866
1283
|
mediaFields?: string[];
|
|
867
1284
|
auth?: "oauth2";
|
|
1285
|
+
username?: string;
|
|
1286
|
+
signal?: AbortSignal;
|
|
1287
|
+
onAttempt?: JsonCommandOptions["onAttempt"];
|
|
1288
|
+
useConfiguredCandidate?: boolean;
|
|
868
1289
|
},
|
|
869
1290
|
): Effect.Effect<XurlUserTweetsResponse, Error> {
|
|
870
1291
|
const query = new URLSearchParams({
|
|
@@ -898,9 +1319,16 @@ export function listUserTweetsEffect(
|
|
|
898
1319
|
}
|
|
899
1320
|
|
|
900
1321
|
const endpoint = `/2/users/${userId}/tweets?${query}`;
|
|
901
|
-
|
|
902
|
-
auth === "oauth2"
|
|
903
|
-
|
|
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(
|
|
904
1332
|
Effect.map((payload) => {
|
|
905
1333
|
const data = Array.isArray(payload.data)
|
|
906
1334
|
? (payload.data as XurlUserTweet[])
|
|
@@ -937,6 +1365,10 @@ export function listUserTweets(
|
|
|
937
1365
|
userFields?: string[];
|
|
938
1366
|
mediaFields?: string[];
|
|
939
1367
|
auth?: "oauth2";
|
|
1368
|
+
username?: string;
|
|
1369
|
+
signal?: AbortSignal;
|
|
1370
|
+
onAttempt?: JsonCommandOptions["onAttempt"];
|
|
1371
|
+
useConfiguredCandidate?: boolean;
|
|
940
1372
|
},
|
|
941
1373
|
): Promise<XurlUserTweetsResponse> {
|
|
942
1374
|
return runEffectPromise(listUserTweetsEffect(userId, options));
|
|
@@ -992,10 +1424,18 @@ export function searchRecentByConversationIdEffect(
|
|
|
992
1424
|
maxResults,
|
|
993
1425
|
paginationToken,
|
|
994
1426
|
timeoutMs,
|
|
1427
|
+
auth,
|
|
1428
|
+
username,
|
|
1429
|
+
signal,
|
|
1430
|
+
onAttempt,
|
|
995
1431
|
}: {
|
|
996
1432
|
maxResults: number;
|
|
997
1433
|
paginationToken?: string;
|
|
998
1434
|
timeoutMs?: number;
|
|
1435
|
+
auth?: "oauth2";
|
|
1436
|
+
username?: string;
|
|
1437
|
+
signal?: AbortSignal;
|
|
1438
|
+
onAttempt?: JsonCommandOptions["onAttempt"];
|
|
999
1439
|
},
|
|
1000
1440
|
): Effect.Effect<XurlTweetsResponse, Error> {
|
|
1001
1441
|
const query = new URLSearchParams({
|
|
@@ -1010,9 +1450,17 @@ export function searchRecentByConversationIdEffect(
|
|
|
1010
1450
|
query.set("pagination_token", paginationToken);
|
|
1011
1451
|
}
|
|
1012
1452
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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));
|
|
1016
1464
|
}
|
|
1017
1465
|
|
|
1018
1466
|
export function searchRecentByConversationId(
|
|
@@ -1021,6 +1469,10 @@ export function searchRecentByConversationId(
|
|
|
1021
1469
|
maxResults: number;
|
|
1022
1470
|
paginationToken?: string;
|
|
1023
1471
|
timeoutMs?: number;
|
|
1472
|
+
auth?: "oauth2";
|
|
1473
|
+
username?: string;
|
|
1474
|
+
signal?: AbortSignal;
|
|
1475
|
+
onAttempt?: JsonCommandOptions["onAttempt"];
|
|
1024
1476
|
},
|
|
1025
1477
|
): Promise<XurlTweetsResponse> {
|
|
1026
1478
|
return runEffectPromise(
|
|
@@ -1028,6 +1480,64 @@ export function searchRecentByConversationId(
|
|
|
1028
1480
|
);
|
|
1029
1481
|
}
|
|
1030
1482
|
|
|
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
|
+
|
|
1031
1541
|
export function getTweetByIdEffect(
|
|
1032
1542
|
id: string,
|
|
1033
1543
|
{ timeoutMs }: { timeoutMs?: number } = {},
|