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