diff --git a/cobalt.ts b/cobalt.ts new file mode 100644 index 0000000..cd6e011 --- /dev/null +++ b/cobalt.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { USER_AGENT } from "./consts"; + +export const CobaltResult = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("error"), + error: z.object({ + code: z.string(), + context: z + .object({ + service: z.string().optional(), + limit: z.number().optional(), + }) + .optional(), + }), + }), + z.object({ + status: z.literal("picker"), + audio: z.string().optional(), + audioFilename: z.string().optional(), + picker: z.array( + z.object({ + type: z.enum(["photo", "video", "gif"]), + url: z.string(), + thumb: z.string().optional(), + }) + ), + }), + z.object({ + status: z.enum(["tunnel", "redirect"]), + url: z.string(), + filename: z.string(), + }), +]); +export type CobaltResult = z.infer; +export type VideoQuality = + | "144" + | "240" + | "360" + | "480" + | "720" + | "1080" + | "1440" + | "2160" + | "4320" + | "max"; +export async function askCobalt( + url: string, + options?: { + videoQuality?: VideoQuality; + } +) { + const response = await fetch(`https://dorsiblancoapicobalt.nulo.in/`, { + method: "POST", + body: JSON.stringify({ url, ...options }), + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": USER_AGENT, + }, + }); + const data = await response.json(); + return CobaltResult.parse(data); +} diff --git a/consts.ts b/consts.ts new file mode 100644 index 0000000..5c29465 --- /dev/null +++ b/consts.ts @@ -0,0 +1 @@ +export const USER_AGENT = "dlbot/1.0 (+https://nulo.lol/dlbot)"; diff --git a/fxtwitter.ts b/fxtwitter.ts new file mode 100644 index 0000000..1b32f4d --- /dev/null +++ b/fxtwitter.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; +import { USER_AGENT } from "./consts"; + +export const FxtwitterResult = z.object({ + code: z.number(), + message: z.string(), + tweet: z.object({ + url: z.string(), + text: z.string(), + created_at: z.string(), + created_timestamp: z.number(), + author: z.object({ + name: z.string(), + screen_name: z.string(), + avatar_url: z.string(), + avatar_color: z.string().nullable(), + banner_url: z.string(), + }), + replies: z.number(), + retweets: z.number(), + likes: z.number(), + views: z.number(), + color: z.string().nullable(), + twitter_card: z.string().optional(), + lang: z.string(), + source: z.string(), + replying_to: z.any(), + replying_to_status: z.any(), + quote: z + .object({ + text: z.string(), + author: z.object({ + name: z.string(), + screen_name: z.string(), + }), + }) + .optional(), + media: z + .object({ + all: z + .array( + z.object({ + type: z.enum(["video", "gif", "photo"]), + url: z.string(), + thumbnail_url: z.string().optional(), + width: z.number(), + height: z.number(), + duration: z.number().optional(), + format: z.string().optional(), + }) + ) + .optional(), + external: z + .object({ + type: z.literal("video"), + url: z.string(), + height: z.number(), + width: z.number(), + duration: z.number(), + }) + .optional(), + photos: z + .array( + z.object({ + type: z.literal("photo"), + url: z.string(), + width: z.number(), + height: z.number(), + }) + ) + .optional(), + videos: z + .array( + z.object({ + type: z.enum(["video", "gif"]), + url: z.string(), + thumbnail_url: z.string(), + width: z.number(), + height: z.number(), + duration: z.number(), + format: z.string(), + }) + ) + .optional(), + mosaic: z + .object({ + type: z.literal("mosaic_photo"), + width: z.number().optional(), + height: z.number().optional(), + formats: z.object({ + webp: z.string(), + jpeg: z.string(), + }), + }) + .optional(), + }) + .optional(), + }), +}); + +export async function askFxtwitter( + screenName: string, + id: string, + translateTo?: string +) { + const url = `https://api.fxtwitter.com/${screenName}/status/${id}/${translateTo}`; + const response = await fetch(url, { + headers: { + "User-Agent": USER_AGENT, + }, + }); + const json = await response.json(); + console.debug("fxtwitter res", JSON.stringify(json)); + if (response.status !== 200) { + throw new Error(`Fxtwitter returned status ${response.status}`); + } + return FxtwitterResult.parse(json); +} diff --git a/index.ts b/index.ts index dd189bd..0ef2b4a 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,8 @@ import TelegramBot from "node-telegram-bot-api"; import { Readable, type Stream } from "stream"; import { z } from "zod"; +import { askCobalt, CobaltResult } from "./cobalt"; +import { askFxtwitter } from "./fxtwitter"; // https://github.com/yagop/node-telegram-bot-api/blob/master/doc/usage.md#file-options-metadata process.env.NTBA_FIX_350 = "false"; @@ -10,67 +12,6 @@ const botParams = { baseApiUrl: process.env.TELEGRAM_API_URL, }; -const CobaltResult = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("error"), - error: z.object({ - code: z.string(), - context: z - .object({ - service: z.string().optional(), - limit: z.number().optional(), - }) - .optional(), - }), - }), - z.object({ - status: z.literal("picker"), - audio: z.string().optional(), - audioFilename: z.string().optional(), - picker: z.array( - z.object({ - type: z.enum(["photo", "video", "gif"]), - url: z.string(), - thumb: z.string().optional(), - }) - ), - }), - z.object({ - status: z.enum(["tunnel", "redirect"]), - url: z.string(), - filename: z.string(), - }), -]); -type CobaltResult = z.infer; -type VideoQuality = - | "144" - | "240" - | "360" - | "480" - | "720" - | "1080" - | "1440" - | "2160" - | "4320" - | "max"; -async function askCobalt( - url: string, - options?: { - videoQuality?: VideoQuality; - } -) { - const response = await fetch(`https://dorsiblancoapicobalt.nulo.in/`, { - method: "POST", - body: JSON.stringify({ url, ...options }), - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - }); - const data = await response.json(); - return CobaltResult.parse(data); -} - class Bot { private bot: TelegramBot; constructor(token: string) { @@ -124,6 +65,49 @@ class Bot { console.log(`Descargando ${parsedUrl.href}`); + if ( + parsedUrl.hostname === "twitter.com" || + parsedUrl.hostname === "x.com" + ) { + try { + const pathParts = parsedUrl.pathname.split("/"); + const statusIndex = pathParts.indexOf("status"); + if (statusIndex !== -1 && statusIndex + 1 < pathParts.length) { + const screenName = pathParts[1]; + const tweetId = pathParts[statusIndex + 1]; + const fxResult = await askFxtwitter(screenName, tweetId); + hasDownloadables = true; + await this.bot.sendMessage( + chatId, + `${fxResult.tweet.author.name} (@${ + fxResult.tweet.author.screen_name + }):\n
${fxResult.tweet.text}${ + fxResult.tweet.quote + ? `
\nQuoting: ${fxResult.tweet.quote.author.name} (@${fxResult.tweet.quote.author.screen_name}):\n
${fxResult.tweet.quote.text}` + : "" + }
\nhttps://fxtwitter.com/${screenName}/status/${tweetId}`, + { reply_to_message_id: msg.message_id, parse_mode: "HTML" } + ); + if (fxResult.tweet.media?.all?.length) { + await this.bot.sendMediaGroup( + chatId, + fxResult.tweet.media?.all?.map((media) => ({ + type: media.type === "gif" ? "photo" : media.type, + media: media.url, + thumb: media.thumbnail_url, + })) ?? [], + { + reply_to_message_id: msg.message_id, + } + ); + } + continue; + } + } catch (error) { + console.error("Failed to fetch from fxtwitter:", error); + } + } + const cobaltResult = await askCobalt(parsedUrl.href); console.log(JSON.stringify(cobaltResult)); if (cobaltResult.status === "error") {