feat(route/telegram): Fix tglib (#18268)

* refactor telegram/tglib for Hono

* linter fix

* add CSP for channel media

* don't cut feed item title prematurely

* implement shared Geo position attachment

* don't start TG client unless routes are requested

* resolve typescript; fix private chats and groups; fix stickers and no-thumbnail document attachments; fix media-by-link attachments

* fix empty text and message grouping; add GeoLive media type; add Poll/Quiz media type

* add script-src none to CSP

Co-authored-by: Tony <TonyRL@users.noreply.github.com>

* add unwrap media for stories support

* fix fwdFrom access

* remove unnecessary core patches, add media route docs

* remove tglib fallback, as it's already preferred if configured

* implement stories endpoint

* improve text representation of channel content

* serve channel media under /channel/entityName/msgId

* improve stories endpoint

* restore original post links to avoid feed duplicates on upgrade

* appease eslint

* fix lint

* improve stories config route definition

* fix poll handling

* lint fix

* format poll results nicely

* fix node compat

* remove duplicate ignore

---------
This commit is contained in:
Aleksandr Bogdanov 2025-09-16 15:41:05 +02:00 committed by GitHub
parent bd63108c12
commit 9d2fc7a676
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 560 additions and 266 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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<Buffer>) {
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);
}

View File

@ -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}`);
}

View File

@ -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;
}

View File

@ -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 `<a href="https://www.google.com/maps/search/?api=1&query=${geo.lat}%2C${geo.long}" target="_blank">Geo LatLon: ${geo.lat}, ${geo.long}</a>`;
}
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 = `<h4>${m.poll.quiz ? 'Quiz' : 'Poll'}: ${m.poll.question.text}</h4>
<div><ul>${m.poll.answers.map((a) => {
let answerTxt = a.text.text;
const result = results.results?.find((r) => r.option.buffer === a.option.buffer);
if (result && results.totalVoters) {
answerTxt = `<strong>${Math.round(result.voters / results.totalVoters * 100)}%</strong>: ${answerTxt}`;
}
return `<li>${answerTxt}</li>`;
}).join('')}</ul></div>
`;
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 `<img src="${src}" alt=""/>`;
}
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 `<video controls preload="metadata" poster="${src}?thumb" width="${vid.w / 2}" height="${vid.h / 2}"><source src="${src}" type="${mime}"></video>`;
}
if (doc && mime.startsWith('audio/')) {
return `<div>${getAudioTitle(m)}</div><div><audio src="${src}"></audio></div>`;
}
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 = `<div><img src="${src}?thumb" alt=""/></div><div>${linkText}</div>`;
}
return `<a href="${src}" target="_blank">${linkText}</a>`;
}
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: <a href="tel:${m.phoneNumber}" target="_blank">${m.firstName} ${m.lastName} ${m.phoneNumber}</a>`;
// TODO: download vCard as media ?
}
if (m instanceof Api.MessageMediaInvoice) {
let description = m.description;
if (m.photo?.url) {
description = `<img src="${m.photo?.url}" /><br />${description}`;
}
return `<h4>${m.test ? 'TEST ' : ''}Invoice: ${m.title}</h4><div>${description}</div>`;
}
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(`<div><a href="${button.url}" target="_blank">${button.text}</a></div>`);
}
}
}
}
if (text !== '' || ++i === messages.length - 1) {
let description = attachments.join('<br/>\n');
attachments = []; // emitting these, buffer other ones
if (message.text) {
if (text) {
description += `<p>${HTMLParser.unparse(message.message, message.entities).replaceAll('\n', '<br/>')}</p>`;
}
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 };
};

View File

@ -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 `<img src="${src}" alt=""/>`;
}
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 `<video controls preload="metadata" poster="${src}?thumb" width="${vid.w / 2}" height="${vid.h / 2}"><source src="${src}" type="${x.document.mimeType}"></video>`;
}
if (x instanceof Api.MessageMediaDocument && x.document.mimeType.startsWith('audio/')) {
return `<audio src="${src}"></audio>`;
}
let linkText = getFilename(x);
export function getFilename(x: Api.TypeMessageMedia) {
if (x instanceof Api.MessageMediaDocument) {
linkText += ` (${humanFileSize(x.document.size)})`;
return `<a href="${src}" target="_blank"><img src="${src}?thumb" alt=""/><br/>${linkText}</a>`;
}
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 };

View File

@ -281,7 +281,7 @@ interface RouteItem {
/**
* The handler function of the route
*/
handler: (ctx: Context) => Promise<Data | null> | Data | null;
handler: (ctx: Context) => Promise<Data | null | Response> | Data | null | Response;
/**
* An example URL of the route