diff --git a/lib/middleware/cache.ts b/lib/middleware/cache.ts index d31a26a5b..ca8821b32 100644 --- a/lib/middleware/cache.ts +++ b/lib/middleware/cache.ts @@ -55,6 +55,10 @@ const middleware: MiddlewareHandler = async (ctx, next) => { // Doesn't hit the cache? We need to let others know! await cacheModule.globalCache.set(controlKey, '1', config.cache.requestTimeout); + // let routers control cache + ctx.set('cacheKey', key); + ctx.set('cacheControlKey', controlKey); + try { await next(); } catch (error) { diff --git a/lib/registry.ts b/lib/registry.ts index 5a4739f5c..de6a9b634 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -198,7 +198,11 @@ for (const namespace in namespaces) { routeData.handler = route.handler; } } - ctx.set('data', await routeData.handler(ctx)); + const response = await routeData.handler(ctx); + if (response instanceof Response) { + return response; + } + ctx.set('data', response); } }; subApp.get(path, wrappedHandler); diff --git a/lib/routes/telegram/channel-media.ts b/lib/routes/telegram/channel-media.ts new file mode 100644 index 000000000..17bf6807d --- /dev/null +++ b/lib/routes/telegram/channel-media.ts @@ -0,0 +1,249 @@ +import InvalidParameterError from '@/errors/types/invalid-parameter'; +import { Route } from '@/types'; +import { Context } from 'hono'; +import { stream } from 'hono/streaming'; +import { Api, TelegramClient } from 'telegram'; +import { IterDownloadFunction } from 'telegram/client/downloads.js'; +import { getAppropriatedPartSize } from 'telegram/Utils.js'; +import { config } from '@/config'; +import cacheModule from '@/utils/cache/index'; +import { getClient, getDocument, getFilename, unwrapMedia } from './tglib/client'; +import { returnBigInt as bigInt } from 'telegram/Helpers.js'; + +/** + * https://core.telegram.org/api/files#stripped-thumbnails + * @param bytes Buffer + * @returns Buffer jpeg + */ +function ExpandInlineBytes(bytes: Buffer) { + if (bytes.length < 3 || bytes[0] !== 0x1) { + throw new Error('cannot inflate a stripped jpeg'); + } + const header = Buffer.from([ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x28, 0x1C, 0x1E, 0x23, 0x1E, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2D, 0x2B, + 0x28, 0x30, 0x3C, 0x64, 0x41, 0x3C, 0x37, 0x37, 0x3C, 0x7B, 0x58, 0x5D, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8F, 0x80, 0x8C, 0x8A, 0xA0, 0xB4, 0xE6, 0xC3, 0xA0, 0xAA, 0xDA, 0xAD, 0x8A, 0x8C, 0xC8, 0xFF, 0xCB, 0xDA, 0xEE, + 0xF5, 0xFF, 0xFF, 0xFF, 0x9B, 0xC1, 0xFF, 0xFF, 0xFF, 0xFA, 0xFF, 0xE6, 0xFD, 0xFF, 0xF8, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x2B, 0x2D, 0x2D, 0x3C, 0x35, 0x3C, 0x76, 0x41, 0x41, 0x76, 0xF8, 0xA5, 0x8C, 0xA5, 0xF8, 0xF8, 0xF8, + 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, + 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, + 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, + 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, + 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, + 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, + 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, + 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, + 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, + 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, + ]); + const footer = Buffer.from([0xFF, 0xD9]); + const real = Buffer.alloc(header.length + bytes.length + footer.length); + header.copy(real); + bytes.copy(real, header.length, 3); + bytes.copy(real, 164, 1, 2); + bytes.copy(real, 166, 2, 3); + footer.copy(real, header.length + bytes.length, 0); + return real; +} + +function sortThumb(thumb: Api.TypePhotoSize) { + if (thumb instanceof Api.PhotoStrippedSize) { + return thumb.bytes.length; + } + if (thumb instanceof Api.PhotoCachedSize) { + return thumb.bytes.length; + } + if (thumb instanceof Api.PhotoSize) { + return thumb.size; + } + if (thumb instanceof Api.PhotoSizeProgressive) { + return Math.max(...thumb.sizes); + } + return 0; +} + +function chooseLargestThumb(thumbs: Api.TypePhotoSize[]) { + thumbs = [...thumbs].sort((a, b) => sortThumb(a) - sortThumb(b)); + return thumbs.pop(); +} + +export async function* streamThumbnail(client: TelegramClient, doc: Api.Document) { + if (doc.thumbs?.length ?? 0 > 0) { + const size = chooseLargestThumb(doc.thumbs!); + if (size instanceof Api.PhotoCachedSize || size instanceof Api.PhotoStrippedSize) { + yield ExpandInlineBytes(size.bytes); + } else { + yield* streamDocument(client, doc, size && 'type' in size ? size.type : ''); + } + return; + } + throw new Error('no thumbnails available'); +} + +export async function* streamDocument(client: TelegramClient, obj: Api.Document, thumbSize = '', offset?: bigInt.BigInteger, limit?: bigInt.BigInteger) { + const chunkSize = (obj.size ? getAppropriatedPartSize(obj.size) : 64) * 1024; + const iterFileParams: IterDownloadFunction = { + file: new Api.InputDocumentFileLocation({ + id: obj.id, + accessHash: obj.accessHash, + fileReference: obj.fileReference, + thumbSize, + }), + chunkSize, + requestSize: 512 * 1024, // MAX_CHUNK_SIZE + dcId: obj.dcId, + offset: undefined, + limit: undefined + }; + if (offset) { + iterFileParams.offset = offset; + } + if (limit) { + iterFileParams.limit = limit.valueOf(); + } + // console.log('starting iterDownload'); + const stream = client.iterDownload(iterFileParams); + yield* stream; + await stream.close(); +} + +function parseRange(range: string, length: bigInt.BigInteger) { + if (!range) { + return []; + } + const [typ, segstr] = range.split('='); + if (typ !== 'bytes') { + throw new InvalidParameterError(`unsupported range: ${typ}`); + } + const segs = segstr.split(',').map((s) => s.trim()); + const parsedSegs: bigInt.BigInteger[][] = []; + for (const seg of segs) { + const range = seg + .split('-', 2) + .filter((v) => !!v) + .map((v) => bigInt(v)); + if (range.length < 2) { + if (seg.startsWith('-')) { + range.unshift(bigInt(0)); + } else { + range.push(length.subtract(bigInt(1))); + } + } + parsedSegs.push(range); + } + return parsedSegs; +} + +export async function configureMiddlewares(ctx: Context) { + // media is too heavy to cache in memory or redis, and lock-up is not needed + await cacheModule.set(ctx.get('cacheControlKey'), '0', config.cache.requestTimeout); + ctx.req.raw.headers.delete('Accept-Encoding'); // avoid hono compress() middleware detecting Accept-Encoding on req +} + +function streamResponse(c: Context, bodyIter: AsyncGenerator) { + return stream(c, async (stream) => { + let aborted = false; + stream.onAbort(() => { + // console.log(`stream aborted`); + aborted = true; + }); + for await (const chunk of bodyIter) { + if (aborted) { break; } + // console.log(`writing ${chunk.length / 1024}kB`); + await stream.write(chunk); + } + // console.log(`done streamResponse`); + }); +} + +export const route: Route = { + path: '/channel/:entityName/:messageId', + categories: ['social-media'], + example: '/channel/telegram/1233', + parameters: { entityName: 'entity name', messageId: 'message id' }, + features: { + requireConfig: [ + { + name: 'TELEGRAM_SESSION', + optional: false, + description: 'Telegram API Authentication', + } + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [], + name: 'Channel Media', + maintainers: ['synchrone'], + handler, + description: ` +::: tip + Serves telegram media like pictures, video or files. +::: +`, +}; + +export async function handleMedia(media: Api.TypeMessageMedia, client: TelegramClient, ctx: Context) { + if (media instanceof Api.MessageMediaPhoto) { + const buf = await client.downloadMedia(media); + return new Response(buf, {headers: {'Content-Type': 'image/jpeg'}}); + } + + const doc = getDocument(media); + if (doc) { + if ('thumb' in ctx.req.query()) { + ctx.header('Content-Type', 'image/jpeg'); + return streamResponse(ctx, streamThumbnail(client, doc)); + } + ctx.header('Content-Type', doc.mimeType); + ctx.header('Accept-Ranges', 'bytes'); + ctx.header('Content-Security-Policy', "default-src 'self'; script-src 'none'"); + + const rangeHeader = ctx.req.header('Range') ?? ''; + const range = parseRange(rangeHeader, doc.size); + if (range.length > 1) { + return ctx.text('Not Satisfiable', 416); + } + + if (range.length === 0) { + ctx.header('Content-Length', doc.size.toString()); + if (!doc.mimeType.startsWith('video/') && + !doc.mimeType.startsWith('audio/') && + !doc.mimeType.startsWith('image/')) { + ctx.header('Content-Disposition', `attachment; filename="${encodeURIComponent(getFilename(media))}"`); + } + return streamResponse(ctx, streamDocument(client, doc)); + } else { + const [offset, limit] = range[0]; + // console.log(`Range: ${rangeHeader}`); + ctx.status(206); // partial content + ctx.header('Content-Length', (limit.subtract(offset).add(1)).toString()); + ctx.header('Content-Range', `bytes ${offset}-${limit}/${doc.size}`); + return streamResponse(ctx, streamDocument(client, doc, '', offset, limit)); + } + } + + return ctx.text(media.className, 415); +} + +export default async function handler(ctx: Context) { + await configureMiddlewares(ctx); + const client = await getClient(); + + const { entityName, messageId } = ctx.req.param(); + const entity = await client.getInputEntity(entityName); + const msgs = await client.getMessages(entity, { + ids: [Number(messageId)], + }); + const media = await unwrapMedia(msgs[0]?.media); + if (!media) { + return ctx.text('Unknown media', 404); + } + + return await handleMedia(media, client, ctx); +} diff --git a/lib/routes/telegram/channel.ts b/lib/routes/telegram/channel.ts index c41b63884..d3d27f59d 100644 --- a/lib/routes/telegram/channel.ts +++ b/lib/routes/telegram/channel.ts @@ -148,6 +148,11 @@ For backward compatibility reasons, invalid \`routeParams\` will be treated as \ }; async function handler(ctx) { + const useWeb = ctx.req.param('routeParams') || !config.telegram.session; + if (!useWeb) { + return tglibchannel(ctx); + } + const username = ctx.req.param('username'); let routeParams = ctx.req.param('routeParams'); let showLinkPreview = true; @@ -219,10 +224,6 @@ async function handler(ctx) { : $('.tgme_widget_message_wrap:not(.tgme_widget_message_wrap:has(.service_message,.tme_no_messages_found))'); // also exclude service messages if (list.length === 0 && $('.tgme_channel_history').length === 0) { - if (config.telegram.session) { - return tglibchannel(ctx); - } - throw new Error(`Unable to fetch message feed from this channel. Please check this URL to see if you can view the message preview: ${resourceUrl}`); } diff --git a/lib/routes/telegram/stories.ts b/lib/routes/telegram/stories.ts new file mode 100644 index 000000000..d6be0e0fe --- /dev/null +++ b/lib/routes/telegram/stories.ts @@ -0,0 +1,128 @@ +/* eslint-disable no-await-in-loop */ +import NotFoundError from '@/errors/types/not-found'; +import { configureMiddlewares, handleMedia } from '@/routes/telegram/channel-media'; +import { Data, DataItem, Route } from '@/types'; +import { Context } from 'hono'; +import { Api} from 'telegram'; +import { getClient, getStory, unwrapMedia } from './tglib/client'; +import { getGeoLink, getMediaLink } from './tglib/channel'; + +export const route: Route = { + path: '/stories/:username/:story?', + categories: ['social-media'], + example: '/stories/sandboxuser1', + parameters: { username: 'entity name', story: 'story' }, + features: { + requireConfig: [ + { + name: 'TELEGRAM_SESSION', + optional: false, + description: 'Telegram API Authentication', + }, + { + name: 'TELEGRAM_API_ID', + optional: true, + description: 'Telegram API ID', + }, + { + name: 'TELEGRAM_API_HASH', + optional: true, + description: 'Telegram API Hash', + }, + { + name: 'TELEGRAM_MAX_CONCURRENT_DOWNLOADS', + optional: true, + description: 'Telegram Max Concurrent Downloads', + }, + { + name: 'TELEGRAM_PROXY_HOST', + optional: true, + description: 'Telegram Proxy Host', + }, + { + name: 'TELEGRAM_PROXY_PORT', + optional: true, + description: 'Telegram Proxy Port', + }, + { + name: 'TELEGRAM_PROXY_SECRET', + optional: true, + description: 'Telegram Proxy Secret', + }, + ], + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [], + name: 'Stories', + maintainers: ['synchrone'], + handler, + description: ``, +}; + +function getMediaAreas(mediaAreas?: Api.TypeMediaArea[]) { + let description = ''; + for (const area of mediaAreas ?? []) { + if (area instanceof Api.MediaAreaChannelPost) { + // TODO: fetch area.msgId and display inline + } else if ((area instanceof Api.MediaAreaGeoPoint || area instanceof Api.MediaAreaVenue) && area.geo instanceof Api.GeoPoint) { + description += getGeoLink(area.geo); + } else if (area instanceof Api.MediaAreaSuggestedReaction) { + if (area.reaction instanceof Api.ReactionEmoji) { + description += area.reaction.emoticon; + } else if (area.reaction instanceof Api.ReactionCustomEmoji) { + // TODO: fetch area.reaction.documentId and display inline + } + } + } + return description; +} + +export default async function handler(ctx: Context) { + const c = await getClient(); + const {username, story} = ctx.req.param(); + if (!username) { + throw new NotFoundError(); + } + const peer = await c.getInputEntity(username); + if (story) { + const storyItem = await getStory(peer, Number(story)); + await configureMiddlewares(ctx); + return await handleMedia(storyItem.media, c, ctx); + } + + const storiesRes = await c.invoke(new Api.stories.GetPeerStories({peer})); + + const item: DataItem[] = []; + for (const story of storiesRes.stories.stories) { + if (!(story instanceof Api.StoryItem)) { // story is deleted (archived) or skipped + continue; + } + const src = `${new URL(ctx.req.url).origin}/telegram/stories/${username}/${story.id}`; + const pubDate = new Date(story.date * 1000).toUTCString(); + const media = await unwrapMedia(story.media); + if (!media) { // cannot load the story + continue; + } + + const description = getMediaLink(src, media) + getMediaAreas(story.mediaAreas); + item.push({ + title: story.caption ?? pubDate, + description, + pubDate, + link: `https://t.me/${username}/s/${story.id}`, + author: username, + }); + } + + return { + title: `Stories of @${username}`, + link: `https://t.me/${username}`, + item, + allowEmpty: ctx.req.param('id') === 'allow_empty', + description: `Stories of @${username} on Telegram`, + } as Data; +} diff --git a/lib/routes/telegram/tglib/channel.ts b/lib/routes/telegram/tglib/channel.ts index 936872c96..c75b2b3ec 100644 --- a/lib/routes/telegram/tglib/channel.ts +++ b/lib/routes/telegram/tglib/channel.ts @@ -1,166 +1,181 @@ -import InvalidParameterError from '@/errors/types/invalid-parameter'; -import { client, decodeMedia, getClient, getFilename, getMediaLink, streamDocument, streamThumbnail } from './client'; -import { returnBigInt } from 'telegram/Helpers.js'; -import { HTMLParser } from 'telegram/extensions/html.js'; +/* eslint-disable no-await-in-loop */ import { DataItem } from '@/types'; -import type { Api } from 'telegram'; +import { Context } from 'hono'; +import { Api } from 'telegram'; +import { HTMLParser } from 'telegram/extensions/html.js'; +import { getClient, getDocument, getFilename, unwrapMedia } from './client'; +import { getDisplayName } from 'telegram/Utils.js'; -function parseRange(range, length) { - if (!range) { - return []; - } - const [typ, segstr] = range.split('='); - if (typ !== 'bytes') { - throw new InvalidParameterError(`unsupported range: ${typ}`); - } - const segs = segstr.split(',').map((s) => s.trim()); - const parsedSegs = []; - for (const seg of segs) { - const range = seg - .split('-', 2) - .filter((v) => !!v) - .map((elem) => returnBigInt(elem)); - if (range.length < 2) { - if (seg.startsWith('-')) { - range.unshift(0); - } else { - range.push(length); - } - } - parsedSegs.push(range); - } - return parsedSegs; +export function getGeoLink(geo: Api.GeoPoint) { + return `Geo LatLon: ${geo.lat}, ${geo.long}`; } -async function getMedia(ctx) { - const media = await decodeMedia(ctx.req.param('username'), ctx.req.param('media')); - if (!media) { - ctx.status = 500; - return ctx.res.end(); +export async function getPollResults(client, message, m: Api.MessageMediaPoll) { + const resultsUpdateResponse = await client.invoke(new Api.messages.GetPollResults({peer: message.peerId, msgId: message.id})); + let results: Api.PollResults; + if (resultsUpdateResponse?.updates[0] instanceof Api.UpdateMessagePoll) { + results = resultsUpdateResponse.updates[0].results as Api.PollResults; } - if (ctx.res.closed) { - // console.log(`prematurely closed ${ctx.req.param('media')}`); - return; + const txt = `

${m.poll.quiz ? 'Quiz' : 'Poll'}: ${m.poll.question.text}

+
+ `; + return txt; +} + +export function getMediaLink(src: string, m: Api.TypeMessageMedia) { + const doc = getDocument(m); + const mime = doc ? doc.mimeType : ''; + + if (m instanceof Api.MessageMediaPhoto || mime.startsWith('image/')) { + return ``; + } + if (doc && mime.startsWith('video/')) { + const vid = (doc.attributes.find((t) => t instanceof Api.DocumentAttributeVideo) ?? { w: 1080, h: 720 }) as {w: number, h: number}; + return ``; + } + if (doc && mime.startsWith('audio/')) { + return `
${getAudioTitle(m)}
`; } - if (media.document) { - ctx.status = 200; - let stream; - if ('thumb' in ctx.req.query()) { - try { - stream = streamThumbnail(media); - ctx.set('Content-Type', 'image/jpeg'); - } catch { - ctx.status = 404; - return ctx.res.end(); - } - } else { - ctx.set('Content-Type', media.document.mimeType); - - ctx.set('Accept-Ranges', 'bytes'); - const range = parseRange(ctx.get('Range'), media.document.size - 1); - if (range.length > 1) { - ctx.status = 416; // range not satisfiable - return ctx.res.end(); - } - if (range.length === 1) { - // console.log(`${ctx.method} ${ctx.req.url} Range: ${ctx.get('Range')}`); - ctx.status = 206; // partial content - const [offset, limit] = range[0]; - ctx.set('Content-Length', limit - offset + 1); - ctx.set('Content-Range', `bytes ${offset}-${limit}/${media.document.size}`); - - const stream = streamDocument(media.document, '', offset, limit); - for await (const chunk of stream) { - ctx.res.write(chunk); - if (ctx.res.closed) { - break; - } - } - return ctx.res.end(); - } - - ctx.set('Content-Length', media.document.size); - if (media.document.mimeType.startsWith('application/')) { - ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(getFilename(media))}"`); - } - stream = streamDocument(media.document); + if (doc && mime.startsWith('application/')) { + let linkText = `${getFilename(m)} (${humanFileSize(doc.size.valueOf())})`; + if (mime.endsWith('x-tgsticker')) { + linkText = ''; // remove filename, it's only an animated sticker } - // const addr = JSON.stringify(ctx.res.socket.address()); - // console.log(`streaming ${ctx.req.param('media')} to ${addr}`); + if ((doc.thumbs?.length ?? 0) > 0) { + linkText = `
${linkText}
`; + } + return `${linkText}`; + } + if ((m instanceof Api.MessageMediaGeo || m instanceof Api.MessageMediaGeoLive) && m.geo instanceof Api.GeoPoint) { + return getGeoLink(m.geo); + } + if (m instanceof Api.MessageMediaWebPage) { + return ''; // a link without a document attach, usually is in the message text, so we can skip here + } + if (m instanceof Api.MessageMediaContact) { + return `Contact: ${m.firstName} ${m.lastName} ${m.phoneNumber}`; + // TODO: download vCard as media ? + } + if (m instanceof Api.MessageMediaInvoice) { + let description = m.description; + if (m.photo?.url) { + description = `
${description}`; + } + return `

${m.test ? 'TEST ' : ''}Invoice: ${m.title}

${description}
`; + } - for await (const chunk of stream) { - if (ctx.res.closed) { - // console.log(`closed ${addr}`); - break; - } - // console.log(`writing ${chunk.length / 1024} to ${addr}`); - ctx.res.write(chunk); + return m.className; +} + +function humanFileSize(size: number) { + const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); + return (size / Math.pow(1024, i)).toFixed(2) + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; +} + +export function getAudioTitle(x: Api.TypeMessageMedia) { + if (x instanceof Api.MessageMediaDocument && x.document instanceof Api.Document) { + const attr = x.document.attributes.find((x) => x instanceof Api.DocumentAttributeAudio); + if (attr) { + return `${attr.performer} - ${attr.title} (${humanDuration(attr.duration)})`; } - if ('close' in stream) { - stream.close(); - } - } else if (media.photo) { - ctx.status = 200; - ctx.set('Content-Type', 'image/jpeg'); - const buf = await client.downloadMedia(media); - ctx.res.write(buf); + } + return getFilename(x); +} + +export function humanDuration(seconds: number) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + // Format time components with leading zeros if necessary + const paddedMinutes = String(minutes).padStart(2, '0'); + const paddedSeconds = String(remainingSeconds).padStart(2, '0'); + + // Construct the time string conditionally + if (hours > 0) { + return `${hours}:${paddedMinutes}:${paddedSeconds}`; // Show hours, minutes, and seconds + } else if (minutes > 0) { + return `${minutes}:${paddedSeconds}`; // Show minutes and seconds } else { - ctx.status = 415; - ctx.write(media.className); + return `0:${paddedSeconds}`; // Show only seconds } - return ctx.res.end(); } -export default async function handler(ctx) { - const { username } = ctx.req.param(); +export default async function handler(ctx: Context) { const client = await getClient(); - - const item: DataItem[] = []; - const chat = (await client.getInputEntity(username)) as Api.InputPeerChannel; - const channelInfo = await client.getEntity(chat); - - if (channelInfo.className !== 'Channel') { - throw new Error(`${username} is not a channel`); - } + const username = ctx.req.param('username'); + const peer = await client.getInputEntity(username); + const entity = await client.getEntity(peer); let attachments: string[] = []; - const messages = await client.getMessages(chat, { limit: 50 }); + const messages = await client.getMessages(peer, { limit: 50 }); + let i = 0; + const item: DataItem[] = []; for (const message of messages) { - if (message.media) { + let text = message.text; // must not be HTML + + if (message.fwdFrom?.fromId) { + const fwdFrom = await client.getEntity(message.fwdFrom.fromId); + text = `Forwarded From: ${getDisplayName(fwdFrom)}: ${text}`; + } + const media = await unwrapMedia(message.media, message.peerId); + if (message.media instanceof Api.MessageMediaStory && media) { // if successfully loaded the story + const storyFrom = await client.getEntity(message.media.peer); + text = `Story From: ${getDisplayName(storyFrom)}: ${text}`; + } + if (media) { + if (media instanceof Api.MessageMediaPoll) { + attachments.push(await getPollResults(client, message, media)); + continue; + } // messages that have no text are shown as if they're one post // because in TG only 1 attachment per message is possible - attachments.push(getMediaLink(ctx, chat, username, message)); + const src = `${new URL(ctx.req.url).origin}/telegram/channel/${username}/${message.id}`; + attachments.push(getMediaLink(src, media)); } - if (message.text !== '') { - let description = attachments.join('\n'); + if (message.replyMarkup instanceof Api.ReplyInlineMarkup) { + for (const buttonRow of message.replyMarkup.rows) { + for (const button of buttonRow.buttons) { + if (button instanceof Api.KeyboardButtonUrl) { + attachments.push(`
${button.text}
`); + } + } + } + } + if (text !== '' || ++i === messages.length - 1) { + let description = attachments.join('
\n'); attachments = []; // emitting these, buffer other ones - if (message.text) { + if (text) { description += `

${HTMLParser.unparse(message.message, message.entities).replaceAll('\n', '
')}

`; } const title = message.text ? message.text.slice(0, 80) + (message.text.length > 80 ? '...' : '') : new Date(message.date * 1000).toUTCString(); - item.push({ title, description, pubDate: new Date(message.date * 1000).toUTCString(), link: `https://t.me/s/${username}/${message.id}`, - author: `${channelInfo.title} (@${username})`, + author: getDisplayName(message.sender ?? entity), }); } } return { - title: channelInfo.title, + title: getDisplayName(entity), language: null, link: `https://t.me/${username}`, item, allowEmpty: ctx.req.param('id') === 'allow_empty', description: `@${username} on Telegram`, }; -} - -export { getMedia }; +}; diff --git a/lib/routes/telegram/tglib/client.ts b/lib/routes/telegram/tglib/client.ts index 4a338f476..d537d2872 100644 --- a/lib/routes/telegram/tglib/client.ts +++ b/lib/routes/telegram/tglib/client.ts @@ -1,7 +1,6 @@ import { Api, TelegramClient } from 'telegram'; import { UserAuthParams } from 'telegram/client/auth'; import { StringSession } from 'telegram/sessions/index.js'; -import { getAppropriatedPartSize } from 'telegram/Utils.js'; import { config } from '@/config'; import ConfigNotFoundError from '@/errors/types/config-not-found'; @@ -34,160 +33,54 @@ export async function getClient(authParams?: UserAuthParams, session?: string) { : undefined, }); - await client.connect(); + await client.start(Object.assign(authParams ?? {}, { + onError: (err: Error) => { throw new Error('Cannot start TG: ' + err); }, + }) as any); return client; } -function humanFileSize(size) { - const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024)); - return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; -} -/** - * https://core.telegram.org/api/files#stripped-thumbnails - * @param bytes Buffer - * @returns Buffer jpeg - */ -function ExpandInlineBytes(bytes) { - if (bytes.length < 3 || bytes[0] !== 0x1) { - return []; - } - const header = Buffer.from([ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x28, 0x1C, 0x1E, 0x23, 0x1E, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2D, 0x2B, - 0x28, 0x30, 0x3C, 0x64, 0x41, 0x3C, 0x37, 0x37, 0x3C, 0x7B, 0x58, 0x5D, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8F, 0x80, 0x8C, 0x8A, 0xA0, 0xB4, 0xE6, 0xC3, 0xA0, 0xAA, 0xDA, 0xAD, 0x8A, 0x8C, 0xC8, 0xFF, 0xCB, 0xDA, 0xEE, - 0xF5, 0xFF, 0xFF, 0xFF, 0x9B, 0xC1, 0xFF, 0xFF, 0xFF, 0xFA, 0xFF, 0xE6, 0xFD, 0xFF, 0xF8, 0xFF, 0xDB, 0x00, 0x43, 0x01, 0x2B, 0x2D, 0x2D, 0x3C, 0x35, 0x3C, 0x76, 0x41, 0x41, 0x76, 0xF8, 0xA5, 0x8C, 0xA5, 0xF8, 0xF8, 0xF8, - 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, - 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xF8, 0xFF, 0xC0, 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, - 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, - 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, - 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, - 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, - 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, - 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xC4, 0x00, 0x1F, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, - 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, - 0x01, 0x02, 0x77, 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, - 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, - 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, - 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE2, - 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x0C, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3F, 0x00, - ]); - const footer = Buffer.from([0xFF, 0xD9]); - const real = Buffer.alloc(header.length + bytes.length + footer.length); - header.copy(real); - bytes.copy(real, header.length, 3); - bytes.copy(real, 164, 1, 2); - bytes.copy(real, 166, 2, 3); - footer.copy(real, header.length + bytes.length, 0); - return real; -} - -function getMediaLink(ctx, channel: Api.InputPeerChannel, channelName: string, message: Api.Message) { - const base = `${ctx.protocol}://${ctx.host}/telegram/channel/${channelName}`; - const src = base + `${channel.channelId}_${message.id}`; - - const x = message.media; - if (x instanceof Api.MessageMediaPhoto || (x instanceof Api.MessageMediaDocument && x.document.mimeType.startsWith('image/'))) { - return ``; - } - if (x instanceof Api.MessageMediaDocument && x.document.mimeType.startsWith('video/')) { - const vid = x.document.attributes.find((t) => t.className === 'DocumentAttributeVideo') ?? { w: 1080, h: 720 }; - return ``; - } - if (x instanceof Api.MessageMediaDocument && x.document.mimeType.startsWith('audio/')) { - return ``; - } - - let linkText = getFilename(x); +export function getFilename(x: Api.TypeMessageMedia) { if (x instanceof Api.MessageMediaDocument) { - linkText += ` (${humanFileSize(x.document.size)})`; - return `
${linkText}
`; - } - return ''; -} -function getFilename(x) { - if (x instanceof Api.MessageMediaDocument) { - const docFilename = x.document.attributes.find((a) => a.className === 'DocumentAttributeFilename'); - if (docFilename) { - return docFilename.fileName; + for (const a of (x.document as Api.Document).attributes) { + if (a instanceof Api.DocumentAttributeFilename) { + return a.fileName; + } } } return x.className; } -function sortThumb(thumb) { - if (thumb instanceof Api.PhotoStrippedSize) { - return thumb.bytes.length; +export function getDocument(m: Api.TypeMessageMedia) { + if (m instanceof Api.MessageMediaDocument && m.document && !(m.document instanceof Api.DocumentEmpty)) { + return m.document; } - if (thumb instanceof Api.PhotoCachedSize) { - return thumb.bytes.length; + if (m instanceof Api.MessageMediaWebPage && m.webpage instanceof Api.WebPage && m.webpage.document instanceof Api.Document) { + return m.webpage.document; } - if (thumb instanceof Api.PhotoSize) { - return thumb.size; - } - if (thumb instanceof Api.PhotoSizeProgressive) { - return Math.max(...thumb.sizes); - } - if (thumb instanceof Api.VideoSize) { - return thumb.size; - } - return 0; } -function chooseLargestThumb(thumbs) { - thumbs = [...thumbs].sort((a, b) => sortThumb(a) - sortThumb(b)); - return thumbs.pop(); +export async function getStory(entity: Api.TypeEntityLike, id: number) { + const result = await (await getClient()).invoke( + new Api.stories.GetStoriesByID({ + id: [id], + peer: entity + }) + ); + return result.stories[0] as Api.StoryItem; } -function streamThumbnail(x) { - if (x instanceof Api.MessageMediaDocument && x.document.thumbs.length > 0) { - const size = chooseLargestThumb(x.document.thumbs); - if (size instanceof Api.PhotoCachedSize || size instanceof Api.PhotoStrippedSize) { - return (function* () { - yield ExpandInlineBytes(size.bytes); - })(); +export async function unwrapMedia(media: Api.TypeMessageMedia | undefined, backupPeerId?: Api.TypePeer) { + if (media instanceof Api.MessageMediaStory) { + if (media.story instanceof Api.StoryItem && media.story.media) { + return media.story.media; } - return streamDocument(x.document, size && 'type' in size ? size.type : ''); - } - throw 'not supported'; -} - -async function decodeMedia(channelName, x, retry = false) { - const [channel, msg] = x.split('_'); - - try { - const msgs = await client.getMessages(channel, { - ids: [Number(msg)], - }); - return msgs[0]?.media; - } catch (error) { - if (!retry) { - // channel likely not seen before, we need to resolve ID and retry - await client.getInputEntity(channelName); - return decodeMedia(channelName, x, true); + let storyItem = await getStory(media.peer, media.id); + if (!storyItem?.media && backupPeerId) { + // it's possible the story got hidden by the original user, but we've saved it into Saved Messages - we can still get it + storyItem = await getStory(backupPeerId, media.id); } - throw error; + return storyItem?.media; } + return media; } - -function streamDocument(obj, thumbSize = '', offset, limit) { - const chunkSize = (obj.size ? getAppropriatedPartSize(obj.size) : 64) * 1024; - const iterFileParams = { - file: new Api.InputDocumentFileLocation({ - id: obj.id, - accessHash: obj.accessHash, - fileReference: obj.fileReference, - thumbSize, - }), - chunkSize, - dcId: obj.dcId, - }; - if (offset) { - iterFileParams.offset = offset; - } - if (limit) { - iterFileParams.limit = limit; - } - return client.iterDownload(iterFileParams); -} - -export { client, getMediaLink, decodeMedia, getFilename, streamDocument, streamThumbnail }; diff --git a/lib/types.ts b/lib/types.ts index 4c5a11ca3..4b4e59762 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -281,7 +281,7 @@ interface RouteItem { /** * The handler function of the route */ - handler: (ctx: Context) => Promise | Data | null; + handler: (ctx: Context) => Promise | Data | null | Response; /** * An example URL of the route