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:
parent
bd63108c12
commit
9d2fc7a676
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue