pi-twitterapi.io 0.1.1

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/src/tool.ts ADDED
@@ -0,0 +1,491 @@
1
+ import { Type, type TSchema } from "typebox";
2
+ import { type ExtensionAPI, keyHint } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { readMergedPiSettings, type PiSettings } from "./settings.js";
5
+ import {
6
+ runTwitterApiAbout,
7
+ runTwitterApiCommunity,
8
+ runTwitterApiFollowers,
9
+ runTwitterApiFollowings,
10
+ runTwitterApiList,
11
+ runTwitterApiMentions,
12
+ runTwitterApiProfile,
13
+ runTwitterApiQuotes,
14
+ runTwitterApiReplies,
15
+ runTwitterApiRetweeters,
16
+ runTwitterApiSearch,
17
+ runTwitterApiSpace,
18
+ runTwitterApiThread,
19
+ runTwitterApiTrends,
20
+ runTwitterApiTweetsByIds,
21
+ runTwitterApiUserSearch,
22
+ runTwitterApiUserTimeline,
23
+ } from "./backend.js";
24
+ import { tweetIdFromInput, type ReplySort, type TwitterApiSearchParams } from "./twitterapi.js";
25
+ import { loadTwitterConfig } from "./config.js";
26
+ import type { TwitterSearchDetails } from "./types.js";
27
+
28
+ export interface TwitterToolOptions {
29
+ env?: NodeJS.ProcessEnv;
30
+ fetcher?: typeof fetch;
31
+ settings?: PiSettings;
32
+ }
33
+
34
+ /** Modes that locate a specific post through the shared `tweet` argument. */
35
+ const TWEET_MODES = new Set(["thread", "replies", "quotes", "retweeters"]);
36
+
37
+ /** Every read mode the tool accepts, in README order. */
38
+ const TWITTER_MODES = [
39
+ "posts",
40
+ "users",
41
+ "thread",
42
+ "user",
43
+ "trends",
44
+ "replies",
45
+ "quotes",
46
+ "mentions",
47
+ "followers",
48
+ "followings",
49
+ "profile",
50
+ "about",
51
+ "tweets",
52
+ "retweeters",
53
+ "community",
54
+ "list",
55
+ "space",
56
+ ] as const;
57
+
58
+ type TwitterMode = (typeof TWITTER_MODES)[number];
59
+
60
+ function isTwitterMode(value: string): value is TwitterMode {
61
+ return (TWITTER_MODES as readonly string[]).includes(value);
62
+ }
63
+
64
+ /**
65
+ * The mode literals as a non-empty tuple, which is what `Type.Union` takes.
66
+ * Built from `TWITTER_MODES` so the schema and the runtime guard cannot drift.
67
+ */
68
+ function modeLiterals(): [TSchema, ...TSchema[]] {
69
+ const [first, ...rest] = TWITTER_MODES;
70
+ return [Type.Literal(first), ...rest.map((name) => Type.Literal(name))];
71
+ }
72
+
73
+ /** Per-mode parameter allowlist, so a parameter that does not apply is refused. */
74
+ const MODE_PARAMS: Record<TwitterMode, readonly string[]> = {
75
+ posts: ["allowed_x_handles", "excluded_x_handles", "from_date", "to_date", "queryType", "count"],
76
+ users: ["count"],
77
+ thread: [],
78
+ user: ["user", "userId", "includeReplies", "limit"],
79
+ trends: ["woeid", "count"],
80
+ replies: ["replySort", "limit"],
81
+ quotes: ["sinceTime", "untilTime", "includeReplies", "limit"],
82
+ mentions: ["user", "sinceTime", "untilTime", "limit"],
83
+ followers: ["user", "pageSize", "limit"],
84
+ followings: ["user", "pageSize", "limit"],
85
+ profile: ["user"],
86
+ tweets: ["ids"],
87
+ community: ["communityId", "limit"],
88
+ list: ["listId", "limit"],
89
+ space: ["spaceId"],
90
+ about: ["user"],
91
+ retweeters: ["limit"],
92
+ };
93
+
94
+ /** Modes that must be told which account to read. */
95
+ const USER_MODES = new Set(["user", "mentions", "followers", "followings", "profile", "about"]);
96
+
97
+ /** The parameter universe, used to report parameters a mode cannot apply. */
98
+ const ALL_PARAMS = [
99
+ "allowed_x_handles",
100
+ "excluded_x_handles",
101
+ "from_date",
102
+ "to_date",
103
+ "queryType",
104
+ "count",
105
+ "user",
106
+ "userId",
107
+ "woeid",
108
+ "includeReplies",
109
+ "sinceTime",
110
+ "untilTime",
111
+ "limit",
112
+ "replySort",
113
+ "ids",
114
+ "pageSize",
115
+ "communityId",
116
+ "listId",
117
+ "spaceId",
118
+ ] as const;
119
+
120
+ export function registerTwitterTool(pi: ExtensionAPI, options: TwitterToolOptions = {}): void {
121
+ const env = options.env ?? process.env;
122
+ const fetcher = options.fetcher ?? fetch;
123
+ const settings = options.settings ?? readMergedPiSettings();
124
+ const config = loadTwitterConfig(settings);
125
+
126
+ pi.registerTool({
127
+ name: "twitter",
128
+ label: "Twitter",
129
+ description:
130
+ "Read X/Twitter via twitterapi.io and return an answer with citation URLs. Modes: posts (default), " +
131
+ "users, thread, user (account timeline), trends, replies, quotes, mentions, followers, followings, " +
132
+ "profile, about, tweets, retweeters, community, list and space. Retrieved content is synthesized into " +
133
+ "an answer by a configured pi model.",
134
+ promptSnippet: "Read X/Twitter via twitterapi.io (posts, users, thread, user timeline, trends, replies, quotes, mentions, followers, followings, profile, about, tweets, retweeters, community, list, space) and return an answer with citation URLs",
135
+ promptGuidelines: [
136
+ "Use twitter when the user needs current discussion or sentiment from X/Twitter and twitterapi.io is configured.",
137
+ "Use mode \"users\" to discover accounts; use mode \"user\" to read a specific account's recent posts.",
138
+ "Use mode \"thread\" with a tweet id or permalink to read a post's whole thread.",
139
+ "Use mode \"replies\" or mode \"quotes\" to read the conversation around a specific post.",
140
+ "Use mode \"retweeters\" with a tweet id or permalink to list the accounts that reposted it.",
141
+ "Use mode \"trends\" with a woeid (1=Worldwide, 23424977=USA) for trending topics.",
142
+ "Use allowed_x_handles and excluded_x_handles with mode \"posts\" to narrow or exclude accounts.",
143
+ "Use from_date and to_date with mode \"posts\" for date ranges; dates must be YYYY-MM-DD.",
144
+ "Do not use twitter as a raw tweet API; it returns an answer and citation URLs, not guaranteed original post objects.",
145
+ ],
146
+ parameters: Type.Object({
147
+ query: Type.String({ description: "Natural-language question or search query. Required for every mode." }),
148
+ mode: Type.Optional(
149
+ Type.Union(modeLiterals(), {
150
+ description: 'What to read: "posts" (default), "users", "thread", "user", "trends", "replies", "quotes", "mentions", "followers", "followings", "profile", "about", "tweets", "retweeters", "community", "list", or "space".',
151
+ }),
152
+ ),
153
+ tweet: Type.Optional(Type.String({ description: 'Post id or X permalink. Required for mode=thread/replies/quotes/retweeters; refused in any other mode.' })),
154
+ user: Type.Optional(Type.String({ description: "X handle (no @) for mode=user, mentions, followers, followings, profile or about." })),
155
+ userId: Type.Optional(Type.String({ description: "Numeric user id for mode=user; preferred over `user` when known." })),
156
+ ids: Type.Optional(Type.Array(Type.String(), { description: "mode=tweets: post ids or X permalinks to fetch (max 100)." })),
157
+ pageSize: Type.Optional(Type.Number({ description: "mode=followers/followings: accounts per page (20–200)." })),
158
+ communityId: Type.Optional(Type.String({ description: "mode=community: the community id." })),
159
+ listId: Type.Optional(Type.String({ description: "mode=list: the list id." })),
160
+ spaceId: Type.Optional(Type.String({ description: "mode=space: the X Space id." })),
161
+ woeid: Type.Optional(Type.Number({ description: "Yahoo Where-On-Earth id for mode=trends (1=Worldwide, 23424977=USA)." })),
162
+ includeReplies: Type.Optional(Type.Boolean({ description: "Include replies: mode=user (timeline) and mode=quotes." })),
163
+ sinceTime: Type.Optional(Type.Number({ description: "mode=quotes/mentions: only items on or after this unix timestamp (seconds)." })),
164
+ untilTime: Type.Optional(Type.Number({ description: "mode=quotes/mentions: only items before this unix timestamp (seconds)." })),
165
+ limit: Type.Optional(Type.Number({ description: "mode=user/mentions/followers/followings/replies/quotes/retweeters/community/list: stop after this many items (max 1000)." })),
166
+ replySort: Type.Optional(
167
+ Type.Union([Type.Literal("Relevance"), Type.Literal("Latest"), Type.Literal("Likes")], {
168
+ description: 'mode=replies sort order: "Relevance" (default), "Latest", or "Likes".',
169
+ }),
170
+ ),
171
+ allowed_x_handles: Type.Optional(Type.Array(Type.String(), { description: "mode=posts: only posts from these handles (max 20, no @)." })),
172
+ excluded_x_handles: Type.Optional(Type.Array(Type.String(), { description: "mode=posts: exclude these handles (max 20, no @)." })),
173
+ from_date: Type.Optional(Type.String({ description: "mode=posts: start date, YYYY-MM-DD." })),
174
+ to_date: Type.Optional(Type.String({ description: "mode=posts: end date, YYYY-MM-DD." })),
175
+ queryType: Type.Optional(
176
+ Type.Union([Type.Literal("Latest"), Type.Literal("Top")], {
177
+ description: 'mode=posts: "Latest" (default, newest first) or "Top" (ranked).',
178
+ }),
179
+ ),
180
+ count: Type.Optional(Type.Number({ description: "mode=posts/users: max items (posts default 10, accounts default 20; max 50). mode=trends: number of trends (min 30)." })),
181
+ }),
182
+
183
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
184
+ const supplied = params as Record<string, unknown>;
185
+ const requested = typeof supplied.mode === "string" ? supplied.mode : "posts";
186
+ if (!isTwitterMode(requested)) {
187
+ throw new Error(
188
+ `twitter mode must be one of ${TWITTER_MODES.map((name) => `"${name}"`).join(", ")} (got "${requested}")`,
189
+ );
190
+ }
191
+ const mode = requested;
192
+
193
+ // The question is validated first: every mode answers it, and a blank one
194
+ // must fail before any retrieval.
195
+ const question = typeof supplied.query === "string" ? supplied.query.trim() : "";
196
+ if (!question) throw new Error("twitter query must not be empty");
197
+
198
+ // `tweet` locates a post for the thread/reply/quote modes, and is refused
199
+ // elsewhere rather than silently ignored.
200
+ const tweet = typeof supplied.tweet === "string" ? supplied.tweet : undefined;
201
+ let tweetReference = "";
202
+ if (TWEET_MODES.has(mode)) {
203
+ if (!tweet || !tweet.trim()) {
204
+ throw new Error(`twitter mode "${mode}" needs a tweet: pass a numeric post id or an X permalink as \`tweet\`.`);
205
+ }
206
+ const id = tweetIdFromInput(tweet);
207
+ if (!id) throw new Error(`twitter tweet must be a numeric post id or an X permalink (got "${tweet}")`);
208
+ tweetReference = `https://x.com/i/status/${id}`;
209
+ } else if (tweet !== undefined) {
210
+ throw new Error(
211
+ `twitter tweet can only be used in modes ${[...TWEET_MODES].map((name) => `"${name}"`).join(", ")} ` +
212
+ `(mode is "${mode}"); remove it or change mode.`,
213
+ );
214
+ }
215
+
216
+ if (mode === "user" && supplied.user === undefined && supplied.userId === undefined) {
217
+ throw new Error('twitter mode "user" needs `user` (a handle) or `userId`.');
218
+ }
219
+ if (USER_MODES.has(mode) && mode !== "user" && supplied.user === undefined) {
220
+ throw new Error(`twitter mode "${mode}" needs \`user\` (an X handle).`);
221
+ }
222
+ if (mode === "tweets") {
223
+ const ids = supplied.ids;
224
+ if (!Array.isArray(ids) || ids.length === 0) {
225
+ throw new Error('twitter mode "tweets" needs `ids` (an array of post ids or permalinks).');
226
+ }
227
+ }
228
+ for (const [required, param] of [
229
+ ["communityId", "community"],
230
+ ["listId", "list"],
231
+ ["spaceId", "space"],
232
+ ] as const) {
233
+ if (mode === param && (supplied[required] === undefined || String(supplied[required]).trim() === "")) {
234
+ throw new Error(`twitter mode "${param}" needs \`${required}\`.`);
235
+ }
236
+ }
237
+ if (mode === "trends" && !Number.isInteger(supplied.woeid)) {
238
+ throw new Error('twitter mode "trends" needs `woeid` (an integer; 1=Worldwide, 23424977=USA).');
239
+ }
240
+
241
+ if (!env.TWITTERAPI_IO_API_KEY) {
242
+ throw new Error(
243
+ "twitter needs credentials: set TWITTERAPI_IO_API_KEY (twitterapi.io). The answer is synthesized by " +
244
+ "twitter.synthesisModel, or by the model running this session when that setting is unset.",
245
+ );
246
+ }
247
+
248
+ // The synthesis model falls back to the model running this session, so a
249
+ // key-only setup works without extra configuration. An explicit
250
+ // twitter.synthesisModel always wins.
251
+ const sessionModelId = ctx?.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
252
+ const synthesisModel = config.synthesisModel ?? sessionModelId;
253
+ const effectiveConfig = synthesisModel === config.synthesisModel ? config : { ...config, synthesisModel };
254
+ // When the configured model fails at runtime, the answer is retried with
255
+ // the session model — but only when it is a different model.
256
+ const fallbackModelIds = config.synthesisModel && sessionModelId ? [sessionModelId] : undefined;
257
+ const base = {
258
+ config: effectiveConfig,
259
+ env,
260
+ fetcher,
261
+ signal,
262
+ registry: ctx?.modelRegistry,
263
+ fallbackModelIds,
264
+ } as const;
265
+
266
+ // A mode that cannot apply a parameter must say so. Silently ignoring it
267
+ // is how an excluded account ends up in a successful answer.
268
+ const disallowed = ALL_PARAMS.filter((name) => !MODE_PARAMS[mode].includes(name));
269
+ const offending = disallowed.filter((name) => supplied[name] !== undefined);
270
+ if (offending.length > 0) {
271
+ const list = offending.join(", ");
272
+ throw new Error(
273
+ `twitter ${list} cannot be applied in mode "${mode}"; remove ${offending.length === 1 ? "it" : "them"}.`,
274
+ );
275
+ }
276
+
277
+ const number = (value: unknown): number | undefined => (typeof value === "number" ? value : undefined);
278
+ const boolean = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
279
+ const text = (value: unknown): string | undefined =>
280
+ typeof value === "string" && value.trim() ? value.trim() : undefined;
281
+
282
+ if (mode === "users") {
283
+ const { markdown, details } = await runTwitterApiUserSearch({
284
+ query: question,
285
+ count: number(supplied.count),
286
+ ...base,
287
+ });
288
+ return { content: [{ type: "text", text: markdown }], details };
289
+ }
290
+
291
+ if (mode === "thread") {
292
+ const { markdown, details } = await runTwitterApiThread({ tweet: tweetReference, query: question, ...base });
293
+ return { content: [{ type: "text", text: markdown }], details };
294
+ }
295
+
296
+ if (mode === "user") {
297
+ const { markdown, details } = await runTwitterApiUserTimeline({
298
+ query: question,
299
+ userName: text(supplied.user),
300
+ userId: text(supplied.userId),
301
+ includeReplies: boolean(supplied.includeReplies),
302
+ limit: number(supplied.limit),
303
+ ...base,
304
+ });
305
+ return { content: [{ type: "text", text: markdown }], details };
306
+ }
307
+
308
+ if (mode === "trends") {
309
+ const { markdown, details } = await runTwitterApiTrends({
310
+ query: question,
311
+ woeid: number(supplied.woeid) as number,
312
+ count: number(supplied.count),
313
+ ...base,
314
+ });
315
+ return { content: [{ type: "text", text: markdown }], details };
316
+ }
317
+
318
+ if (mode === "replies") {
319
+ const { markdown, details } = await runTwitterApiReplies({
320
+ query: question,
321
+ tweet: tweetReference,
322
+ queryType: text(supplied.replySort) as ReplySort | undefined,
323
+ limit: number(supplied.limit),
324
+ ...base,
325
+ });
326
+ return { content: [{ type: "text", text: markdown }], details };
327
+ }
328
+
329
+ if (mode === "quotes") {
330
+ const { markdown, details } = await runTwitterApiQuotes({
331
+ query: question,
332
+ tweet: tweetReference,
333
+ sinceTime: number(supplied.sinceTime),
334
+ untilTime: number(supplied.untilTime),
335
+ includeReplies: boolean(supplied.includeReplies),
336
+ limit: number(supplied.limit),
337
+ ...base,
338
+ });
339
+ return { content: [{ type: "text", text: markdown }], details };
340
+ }
341
+
342
+ if (mode === "mentions") {
343
+ const { markdown, details } = await runTwitterApiMentions({
344
+ query: question,
345
+ userName: text(supplied.user) as string,
346
+ sinceTime: number(supplied.sinceTime),
347
+ untilTime: number(supplied.untilTime),
348
+ limit: number(supplied.limit),
349
+ ...base,
350
+ });
351
+ return { content: [{ type: "text", text: markdown }], details };
352
+ }
353
+
354
+ if (mode === "followers" || mode === "followings") {
355
+ const runner = mode === "followers" ? runTwitterApiFollowers : runTwitterApiFollowings;
356
+ const { markdown, details } = await runner({
357
+ query: question,
358
+ userName: text(supplied.user) as string,
359
+ pageSize: number(supplied.pageSize),
360
+ limit: number(supplied.limit),
361
+ ...base,
362
+ });
363
+ return { content: [{ type: "text", text: markdown }], details };
364
+ }
365
+
366
+ if (mode === "profile") {
367
+ const { markdown, details } = await runTwitterApiProfile({
368
+ query: question,
369
+ userName: text(supplied.user) as string,
370
+ ...base,
371
+ });
372
+ return { content: [{ type: "text", text: markdown }], details };
373
+ }
374
+
375
+ if (mode === "tweets") {
376
+ const ids = (supplied.ids as unknown[]).map((value) => String(value));
377
+ const { markdown, details } = await runTwitterApiTweetsByIds({ query: question, ids, ...base });
378
+ return { content: [{ type: "text", text: markdown }], details };
379
+ }
380
+
381
+ if (mode === "about") {
382
+ const { markdown, details } = await runTwitterApiAbout({
383
+ query: question,
384
+ userName: text(supplied.user) as string,
385
+ ...base,
386
+ });
387
+ return { content: [{ type: "text", text: markdown }], details };
388
+ }
389
+
390
+ if (mode === "retweeters") {
391
+ const { markdown, details } = await runTwitterApiRetweeters({
392
+ query: question,
393
+ tweet: tweetReference,
394
+ limit: number(supplied.limit),
395
+ ...base,
396
+ });
397
+ return { content: [{ type: "text", text: markdown }], details };
398
+ }
399
+
400
+ if (mode === "community") {
401
+ const { markdown, details } = await runTwitterApiCommunity({
402
+ query: question,
403
+ communityId: text(supplied.communityId) as string,
404
+ limit: number(supplied.limit),
405
+ ...base,
406
+ });
407
+ return { content: [{ type: "text", text: markdown }], details };
408
+ }
409
+
410
+ if (mode === "list") {
411
+ const { markdown, details } = await runTwitterApiList({
412
+ query: question,
413
+ listId: text(supplied.listId) as string,
414
+ limit: number(supplied.limit),
415
+ ...base,
416
+ });
417
+ return { content: [{ type: "text", text: markdown }], details };
418
+ }
419
+
420
+ if (mode === "space") {
421
+ const { markdown, details } = await runTwitterApiSpace({
422
+ query: question,
423
+ spaceId: text(supplied.spaceId) as string,
424
+ ...base,
425
+ });
426
+ return { content: [{ type: "text", text: markdown }], details };
427
+ }
428
+
429
+ const { markdown, details } = await runTwitterApiSearch({
430
+ params: { ...(params as TwitterApiSearchParams), query: question },
431
+ ...base,
432
+ });
433
+ return { content: [{ type: "text", text: markdown }], details };
434
+ },
435
+
436
+ renderCall(args, theme, context) {
437
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
438
+ let line = theme.fg("toolTitle", theme.bold("twitter "));
439
+ const mode = typeof (args as { mode?: unknown }).mode === "string" ? (args as { mode: string }).mode : "posts";
440
+ if (mode !== "posts") line += theme.fg("warning", `[${mode}] `);
441
+ const subject =
442
+ mode !== "posts" && (args as { tweet?: unknown }).tweet !== undefined
443
+ ? String((args as { tweet?: unknown }).tweet)
444
+ : mode === "user" || mode === "trends"
445
+ ? String((args as { user?: unknown }).user ?? (args as { woeid?: unknown }).woeid ?? args.query ?? "")
446
+ : args.query ?? "";
447
+ line += theme.fg("accent", subject);
448
+ const handles = args.allowed_x_handles ?? args.excluded_x_handles;
449
+ if (Array.isArray(handles) && handles.length > 0) line += theme.fg("muted", ` · ${handles.map((handle) => `@${handle}`).join(",")}`);
450
+ if (args.from_date || args.to_date) line += theme.fg("dim", ` · ${args.from_date ?? "…"}..${args.to_date ?? "…"}`);
451
+ text.setText(line);
452
+ return text;
453
+ },
454
+
455
+ renderResult(result, options, theme, context) {
456
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
457
+
458
+ if (options.isPartial) {
459
+ text.setText(theme.fg("muted", "Reading X…"));
460
+ return text;
461
+ }
462
+
463
+ if (context.isError || !result.details) {
464
+ const raw = result.content.find((content) => content.type === "text")?.text ?? "";
465
+ text.setText(theme.fg("error", raw));
466
+ return text;
467
+ }
468
+
469
+ const details = result.details as TwitterSearchDetails;
470
+ const header = theme.fg("success", `✓ Twitter`) + theme.fg("muted", ` · ${details.citations.length} citations · ${details.synthesisCalls ?? 0} synthesis calls`);
471
+ const sources = details.citations.slice(0, options.expanded ? details.citations.length : 5);
472
+ const rows = sources.map((url, index) => `${theme.fg("dim", `${index + 1}.`)} ${theme.fg("accent", url)}`);
473
+ let body = rows.length > 0 ? `\n${rows.join("\n")}` : "";
474
+ const remaining = details.citations.length - sources.length;
475
+ if (remaining > 0) {
476
+ body += theme.fg("muted", `\n… (${remaining} more citations, `) + keyHint("app.tools.expand", "to expand") + theme.fg("muted", ")");
477
+ }
478
+ // Notes carry disclosures (coverage gap, skipped media, dropped links).
479
+ // Showing them collapsed would hide information the markdown result
480
+ // already surfaces, so they appear once the user expands the call.
481
+ if (details.notes?.length && options.expanded) {
482
+ body += "\n" + details.notes.map((note) => theme.fg("muted", `• ${note}`)).join("\n");
483
+ } else if (details.notes?.length) {
484
+ body += theme.fg("muted", `\n• ${details.notes.length} note(s), `) + keyHint("app.tools.expand", "to expand");
485
+ }
486
+
487
+ text.setText(header + body);
488
+ return text;
489
+ },
490
+ });
491
+ }
@@ -0,0 +1,124 @@
1
+ export const TWITTERAPI_BASE_URL = "https://api.twitterapi.io";
2
+ export const ADVANCED_SEARCH_PATH = "/twitter/tweet/advanced_search";
3
+ export interface TwitterApiSearchParams {
4
+ query: string;
5
+ allowed_x_handles?: string[];
6
+ excluded_x_handles?: string[];
7
+ from_date?: string;
8
+ to_date?: string;
9
+ queryType?: "Latest" | "Top";
10
+ count?: number;
11
+ }
12
+
13
+ export interface NormalizedSearchParams extends TwitterApiSearchParams {
14
+ query: string;
15
+ }
16
+
17
+ export interface TweetAuthor {
18
+ userName?: string;
19
+ name?: string;
20
+ followers?: number;
21
+ }
22
+
23
+ export interface TweetMedia {
24
+ /** "photo", "video", "animated_gif". */
25
+ type?: string;
26
+ /** Direct media URL. For videos this is the poster frame (a JPEG). */
27
+ url?: string;
28
+ /** Playable variants, highest bitrate last (videos only). */
29
+ videoVariants?: string[];
30
+ durationMillis?: number;
31
+ }
32
+
33
+ export interface Tweet {
34
+ id?: string;
35
+ url?: string;
36
+ text?: string;
37
+ createdAt?: string;
38
+ likeCount?: number;
39
+ retweetCount?: number;
40
+ replyCount?: number;
41
+ viewCount?: number;
42
+ author?: TweetAuthor;
43
+ /** Populated from extendedEntities.media when the post carries media. */
44
+ media?: TweetMedia[];
45
+ }
46
+
47
+ /** Why pagination stopped. Surfaced so incomplete retrieval is never presented as complete. */
48
+ export type SearchTermination = "target" | "exhausted" | "page-cap" | "cursor-cycle" | "cursor-missing";
49
+
50
+ export interface SearchDetails {
51
+ query: string;
52
+ expression: string;
53
+ queryType: string;
54
+ tweets: Tweet[];
55
+ /** Pages actually fetched (logical requests; retries are not counted). */
56
+ pagesFetched?: number;
57
+ /** Present when date filters were applied — the local-day window enforced client-side. */
58
+ window?: LocalWindow;
59
+ /** Why pagination stopped. */
60
+ stoppedBy?: SearchTermination;
61
+ /**
62
+ * Upstream posts discarded because they are NEWER than the requested local
63
+ * window — the upstream 04:00 UTC band that sits in front of the requested
64
+ * posts. A large count means pages (and credits) were spent reaching the day.
65
+ */
66
+ trimmedNewer?: number;
67
+ /** Upstream posts discarded because they are OLDER than the window (start padding). */
68
+ trimmedOlder?: number;
69
+ /**
70
+ * True when retrieval stopped while the upstream had more pages (`page-cap`
71
+ * or `cursor-cycle`). Callers must not treat the result as complete coverage.
72
+ */
73
+ truncated?: boolean;
74
+ }
75
+ export function isObject(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
79
+ export interface LocalWindow {
80
+ /** Inclusive start, epoch ms. */
81
+ startMs: number;
82
+ /** Exclusive end, epoch ms. */
83
+ endMs: number;
84
+ /** Local date requested as the start, when given. */
85
+ fromDate?: string;
86
+ /** Local date requested as the end, when given. */
87
+ toDate?: string;
88
+ /** Human-readable zone used to resolve the boundaries. */
89
+ zone: string;
90
+ /** Hours at the end of the window the upstream bound cannot reach (0 = full coverage). */
91
+ shortfallHours: number;
92
+ /**
93
+ * Hours of *newest* upstream posts that fall after the window end and are
94
+ * discarded client-side, because the upstream resolves date bounds at 04:00
95
+ * UTC. Zero for any offset at or west of UTC-4; positive further east, where
96
+ * the discarded band sits in front of the requested posts in a newest-first
97
+ * scan.
98
+ */
99
+ trimHours: number;
100
+ }
101
+ /**
102
+ * An X account from `/twitter/user/search`.
103
+ *
104
+ * The published schema does not match the live response: the handle is
105
+ * `screen_name` (the documented `userName` is absent and `username` is null),
106
+ * the counts are `followers_count` / `following_count`, and `url` is a **t.co
107
+ * redirect** rather than the profile link the docs promise. So `profileUrl` is
108
+ * constructed, and an account without a handle is dropped instead of being
109
+ * published with an unusable link.
110
+ */
111
+ export interface UserProfile {
112
+ id?: string;
113
+ /** Handle without "@". */
114
+ handle: string;
115
+ name?: string;
116
+ bio?: string;
117
+ followers?: number;
118
+ following?: number;
119
+ verified?: boolean;
120
+ /** Always constructed: `https://x.com/<handle>`. */
121
+ profileUrl: string;
122
+ location?: string;
123
+ createdAt?: string;
124
+ }