twitter.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import { z } from "https://deno.land/x/zod@v3.8.0/mod.ts";
  2. import { TokenProvider } from "./token.ts";
  3. const TWITTER_API = 'https://api.twitter.com';
  4. function object<T extends z.ZodRawShape>(shape: T): z.ZodObject<T, "passthrough"> {
  5. return z.object(shape).passthrough();
  6. }
  7. const IndicesSchema = z.tuple([ z.number(), z.number() ]);
  8. const BaseUserSchema = object({
  9. id_str: z.string(),
  10. name: z.string(),
  11. screen_name: z.string(),
  12. });
  13. const UserSchema = BaseUserSchema.extend({
  14. profile_image_url_https: z.string(),
  15. });
  16. export type User = z.infer<typeof UserSchema>;
  17. const HashtagSchema = object({
  18. text: z.string(),
  19. indices: IndicesSchema,
  20. });
  21. const UrlSchema = object({
  22. display_url: z.string(),
  23. expanded_url: z.string(),
  24. url: z.string(),
  25. indices: IndicesSchema,
  26. });
  27. const BaseTweetSchema = object({
  28. id_str: z.string(),
  29. created_at: z.string(),
  30. full_text: z.string(),
  31. user: UserSchema,
  32. display_text_range: IndicesSchema,
  33. lang: z.optional(z.string()),
  34. entities: object({
  35. hashtags: z.array(HashtagSchema),
  36. urls: z.array(UrlSchema),
  37. user_mentions: z.array(BaseUserSchema.extend({
  38. indices: IndicesSchema,
  39. })),
  40. }),
  41. is_quote_status: z.boolean(),
  42. extended_entities: z.optional(object({
  43. media: z.array(object({
  44. display_url: z.string(),
  45. media_url_https: z.string(),
  46. expanded_url: z.string(),
  47. indices: IndicesSchema,
  48. type: z.enum(['photo', 'video', 'animated_gif']),
  49. video_info: z.optional(object({
  50. variants: z.array(object({
  51. bitrate: z.optional(z.number()),
  52. content_type: z.string(),
  53. url: z.string(),
  54. })),
  55. })),
  56. })),
  57. })),
  58. in_reply_to_user_id_str: z.union([z.string(), z.null()]),
  59. });
  60. interface TweetRecurse {
  61. quoted_status?: Tweet,
  62. retweeted_status?: Tweet,
  63. }
  64. export type Tweet = z.infer<typeof BaseTweetSchema> & TweetRecurse;
  65. export const TweetSchema: z.ZodSchema<Tweet> = z.lazy(() => BaseTweetSchema.extend({
  66. quoted_status: z.optional(TweetSchema),
  67. retweeted_status: z.optional(TweetSchema),
  68. }));
  69. export const TimelineSchema = z.array(TweetSchema);
  70. export class TwitterClient {
  71. constructor(private readonly tokenProvider: TokenProvider) {}
  72. async getTimeline(username: string, newerThanId?: string): Promise<Tweet[]> {
  73. const options: Record<string, string> = {
  74. screen_name: username,
  75. trim_user: 'false',
  76. include_rts: 'true',
  77. tweet_mode: 'extended',
  78. };
  79. if (newerThanId !== undefined) {
  80. options.since_id = newerThanId;
  81. options.count = '100';
  82. }
  83. const result = await this.twitterGet(`/1.1/statuses/user_timeline.json`, options);
  84. return TimelineSchema.parse(result);
  85. }
  86. private async twitterGet(path: string, params: Record<string, string> = {}): Promise<unknown> {
  87. const url = new URL(`${TWITTER_API}${path}`);
  88. url.search = new URLSearchParams(params).toString();
  89. const result = await fetch(url, {
  90. headers: {
  91. Authorization: `Bearer ${await this.tokenProvider.getToken()}`
  92. }
  93. });
  94. if (result.status < 200 || result.status >= 300) {
  95. throw new Error(await result.text());
  96. }
  97. return result.json();
  98. }
  99. }