import { z } from "https://deno.land/x/zod@v3.8.0/mod.ts"; import { TokenProvider } from "./token.ts"; const TWITTER_API = 'https://api.twitter.com'; function object(shape: T): z.ZodObject { return z.object(shape).passthrough(); } const IndicesSchema = z.tuple([ z.number(), z.number() ]); const BaseUserSchema = object({ id_str: z.string(), name: z.string(), screen_name: z.string(), }); const UserSchema = BaseUserSchema.extend({ profile_image_url_https: z.string(), }); export type User = z.infer; const HashtagSchema = object({ text: z.string(), indices: IndicesSchema, }); const UrlSchema = object({ display_url: z.string(), expanded_url: z.string(), url: z.string(), indices: IndicesSchema, }); const BaseTweetSchema = object({ id_str: z.string(), created_at: z.string(), full_text: z.string(), user: UserSchema, display_text_range: IndicesSchema, lang: z.optional(z.string()), entities: object({ hashtags: z.array(HashtagSchema), urls: z.array(UrlSchema), user_mentions: z.array(BaseUserSchema.extend({ indices: IndicesSchema, })), }), is_quote_status: z.boolean(), extended_entities: z.optional(object({ media: z.array(object({ display_url: z.string(), media_url_https: z.string(), expanded_url: z.string(), indices: IndicesSchema, type: z.enum(['photo', 'video', 'animated_gif']), video_info: z.optional(object({ variants: z.array(object({ bitrate: z.optional(z.number()), content_type: z.string(), url: z.string(), })), })), })), })), in_reply_to_user_id_str: z.union([z.string(), z.null()]), }); interface TweetRecurse { quoted_status?: Tweet, retweeted_status?: Tweet, } export type Tweet = z.infer & TweetRecurse; export const TweetSchema: z.ZodSchema = z.lazy(() => BaseTweetSchema.extend({ quoted_status: z.optional(TweetSchema), retweeted_status: z.optional(TweetSchema), })); export const TimelineSchema = z.array(TweetSchema); export class TwitterClient { constructor(private readonly tokenProvider: TokenProvider) {} async getTimeline(username: string, newerThanId?: string): Promise { const options: Record = { screen_name: username, trim_user: 'false', include_rts: 'true', tweet_mode: 'extended', }; if (newerThanId !== undefined) { options.since_id = newerThanId; options.count = '100'; } const result = await this.twitterGet(`/1.1/statuses/user_timeline.json`, options); return TimelineSchema.parse(result); } private async twitterGet(path: string, params: Record = {}): Promise { const url = new URL(`${TWITTER_API}${path}`); url.search = new URLSearchParams(params).toString(); const result = await fetch(url, { headers: { Authorization: `Bearer ${await this.tokenProvider.getToken()}` } }); if (result.status < 200 || result.status >= 300) { throw new Error(await result.text()); } return result.json(); } }