From c902fb9efc0ad86df847c83313b1ce0aa5fd358a Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 17:08:07 +0300 Subject: [PATCH 01/11] feat: Implement group chat support --- locales/en.ftl | 1 + locales/ru.ftl | 1 + src/telegram/bot/download.ts | 45 ++++++++++++++++++------------- src/telegram/bot/info.ts | 2 +- src/telegram/bot/settings.ts | 47 ++++++++++++++++++++++++--------- src/telegram/bot/start.ts | 2 +- src/telegram/bot/stats.ts | 4 +-- src/telegram/helpers/handler.ts | 7 +++-- 8 files changed, 70 insertions(+), 39 deletions(-) diff --git a/locales/en.ftl b/locales/en.ftl index 6f64934..43a6dd2 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -7,6 +7,7 @@ error = { error-title }: { $message } { $error-emoticon } error-not-url = i couldn't find url in your message error-request-not-found = looks like i forgot your link, try sending it again error-not-button-owner = looks like this button is not yours (¬_¬") +error-admin-button = only admins can touch this button!! error-too-large = sorry, but this file is too big - telegram doesn't allow me to upload it error-invalid-response = server response is invalid, maybe it's down or encountered an internal error error-unresponsive = couldn't connect to this server, maybe it's down... diff --git a/locales/ru.ftl b/locales/ru.ftl index 4d5af9a..6de47c8 100644 --- a/locales/ru.ftl +++ b/locales/ru.ftl @@ -7,6 +7,7 @@ error = { error-title }: { $message } { $error-emoticon } error-not-url = я не нашёл ссылки в твоём сообщении error-request-not-found = похоже я потерял твою ссылку, можешь отправить её снова? error-not-button-owner = похоже что эта кнопка не твоя (¬_¬") +error-admin-button = только админам можно тыкать эту кнопку!! error-too-large = этот файл слишком большой, к сожалению тг не даёт его загрузить error-invalid-response = сервер некорректно ответил, возможно он столкнулся с внутренней ошибкой или лежит error-unresponsive = не удалось подключиться к серверу, наверное он лежит... diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index b9bc4e3..b04d694 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -1,11 +1,13 @@ +import type { BusinessCallbackQueryContext, CallbackQueryContext, InlineCallbackQueryContext } from "@mtcute/dispatcher" import type { InputMediaLike, Peer } from "@mtcute/node" import { randomUUID } from "node:crypto" -import { Dispatcher, filters } from "@mtcute/dispatcher" +import { Dispatcher } from "@mtcute/dispatcher" import { BotInline, BotKeyboard } from "@mtcute/node" import type { MediaRequest } from "@/core/data/request" import { createRequest, getRequest } from "@/core/data/request" +import type { Settings } from "@/core/data/settings" import { incrementDownloadCount } from "@/core/data/stats" import { getOutputSelectionMessage, @@ -18,8 +20,8 @@ import { evaluatorsFor } from "@/telegram/helpers/text" export const downloadDp = Dispatcher.child() -downloadDp.onNewMessage(filters.chat("user"), async (msg) => { - const { e, t } = await evaluatorsFor(msg.sender) +downloadDp.onNewMessage(async (msg) => { + const { e, t } = await evaluatorsFor(msg.chat) if (msg.text === "meow") { await msg.replyText("meow :з") @@ -31,7 +33,8 @@ downloadDp.onNewMessage(filters.chat("user"), async (msg) => { const req = await createRequest(extractedUrl || msg.text, msg.sender.id) if (!req.success) { - await msg.replyText(t("error", { message: e(req.error) })) + if (msg.chat.type === "user") + await msg.replyText(t("error", { message: e(req.error) })) return } @@ -45,16 +48,16 @@ downloadDp.onNewMessage(filters.chat("user"), async (msg) => { ]), }) - const settings = await getPeerSettings(msg.sender) + const settings = await getPeerSettings(msg.chat) if (settings.preferredOutput) { await onOutputSelected( settings.preferredOutput, req.result, args => msg.client.editMessage({ ...args, message: reply }), { e, t }, - msg.sender, - !!settings.preferredAttribution, + settings, ({ medias }) => msg.replyMediaGroup(medias), + msg.sender, ) } }) @@ -107,8 +110,13 @@ downloadDp.onInlineQuery(async (ctx) => { }) downloadDp.onAnyCallbackQuery(OutputButton.filter(), async (upd) => { - const settings = await getPeerSettings(upd.user) - const { t, e } = await evaluatorsFor(upd.user) + // When passing a filter to onAnyCallbackQuery it applies a modification to the update object, which makes it lose its enum-like properties. + // To access the original update object, we need to cast it to the original type. + const rawUpd = upd as unknown as (CallbackQueryContext | InlineCallbackQueryContext | BusinessCallbackQueryContext) + + const peer = rawUpd._name === "callback_query" ? rawUpd.chat : upd.user + const settings = await getPeerSettings(peer) + const { t, e } = await evaluatorsFor(peer) const { output: outputType, request: requestId } = upd.match const request = await getRequest(requestId) @@ -123,9 +131,9 @@ downloadDp.onAnyCallbackQuery(OutputButton.filter(), async (upd) => { request, args => upd.editMessage(args), { t, e }, + settings, + ({ medias }) => upd.client.sendMediaGroup(peer.id, medias), upd.user, - !!settings.preferredAttribution, - ({ medias }) => upd.client.sendMediaGroup(upd.user.id, medias), ) }) @@ -141,9 +149,9 @@ downloadDp.onChosenInlineResult(async (upd) => { request, args => upd.editMessage({ ...args, messageId }), await evaluatorsFor(upd.user), - upd.user, - !!settings.preferredAttribution, + settings, ({ medias }) => upd.client.sendMediaGroup(upd.user.id, medias), + upd.user, ) } }) @@ -153,12 +161,12 @@ async function onOutputSelected( request: MediaRequest | undefined, editMessage: (edit: { text?: string, media?: InputMediaLike }) => Promise, { t, e }: Evaluators, - peer: Peer, - leaveSourceLink: boolean, + settings: Settings, sendGroup: (send: { medias: InputMediaLike[] }) => Promise, + sender: Peer, ) { await editMessage({ text: t("downloading-title") }) - const res = await handleMediaDownload(outputType, request, peer) + const res = await handleMediaDownload(outputType, request, settings) if (!res.success) { const errorMessage = t("error", { message: e(res.error) }) await editMessage({ text: leaveSourceLink ? `${errorMessage}\n\n${request?.url}` : errorMessage }) @@ -174,10 +182,9 @@ async function onOutputSelected( await sendGroup({ medias: chunk }) } } else { - await editMessage({ media: res.result[0] }) - await editMessage({ text: (leaveSourceLink && request?.url) || "" }) + await editMessage({ media: res.result[0], text: (!!settings.preferredAttribution && request?.url) || "" }) } - incrementDownloadCount(peer.id) + incrementDownloadCount(sender.id) .catch(() => { /* noop */ }) } diff --git a/src/telegram/bot/info.ts b/src/telegram/bot/info.ts index ec14495..ea1fd4e 100644 --- a/src/telegram/bot/info.ts +++ b/src/telegram/bot/info.ts @@ -8,7 +8,7 @@ export const infoDp = Dispatcher.child() infoDp.onNewMessage( filters.or(filters.command("info"), filters.deeplink(["info"])), async (msg) => { - const { t } = await evaluatorsFor(msg.sender) + const { t } = await evaluatorsFor(msg.chat) const infoText = t("info", { bugs, name, repository, version, homepage }) await msg.replyText(`${infoText}\n\n${env.ADDITIONAL_INFO}`) }, diff --git a/src/telegram/bot/settings.ts b/src/telegram/bot/settings.ts index 6f436d2..602158f 100644 --- a/src/telegram/bot/settings.ts +++ b/src/telegram/bot/settings.ts @@ -1,3 +1,4 @@ +import type { Peer, TelegramClient, User } from "@mtcute/node" import { Dispatcher, filters, PropagationAction } from "@mtcute/dispatcher" import { BotKeyboard } from "@mtcute/node" @@ -34,6 +35,13 @@ function settingsMessage(e: TextEvaluator, settings: Settings) { } } +async function isAdmin(client: Pick, chat: Peer, user: User) { + if (chat.type !== "chat") + return true + const member = await client.getChatMember({ chatId: chat, userId: user }) + return member?.status === "admin" || member?.status === "creator" +} + function settingEditMessage(e: TextEvaluator, settings: Settings, setting: keyof Settings) { const menu = getSettingMenu(settings, setting) return { @@ -49,16 +57,21 @@ function settingEditMessage(e: TextEvaluator, settings: Settings, setting: keyof settingsDp.onNewMessage( filters.or(filters.command("settings"), filters.deeplink(["settings"])), async (msg) => { - const { e } = await evaluatorsFor(msg.sender) - const settings = await getPeerSettings(msg.sender) + const { e } = await evaluatorsFor(msg.chat) + const settings = await getPeerSettings(msg.chat) const { text, ...props } = settingsMessage(e, settings) await msg.replyText(text, props) }, ) -settingsDp.onAnyCallbackQuery(SettingButton.filter(), async (upd) => { - const { e } = await evaluatorsFor(upd.user) - const settings = await getPeerSettings(upd.user) +settingsDp.onCallbackQuery(SettingButton.filter(), async (upd) => { + const { e, t } = await evaluatorsFor(upd.chat) + if (!await isAdmin(upd.client, upd.chat, upd.user)) { + return await upd.answer({ + text: t("error-admin-button"), + }) + } + const settings = await getPeerSettings(upd.chat) if (upd.match.setting === "back") { await upd.editMessage(settingsMessage(e, settings)) return @@ -69,36 +82,46 @@ settingsDp.onAnyCallbackQuery(SettingButton.filter(), async (upd) => { await upd.editMessage(settingEditMessage(e, settings, upd.match.setting)) }) -settingsDp.onAnyCallbackQuery(SettingUpdateButton.filter(), async (upd, state) => { +settingsDp.onCallbackQuery(SettingUpdateButton.filter(), async (upd, state) => { if (!isValidSettingKey(upd.match.setting)) return // Invalid key + if (!await isAdmin(upd.client, upd.chat, upd.user)) { + const { t } = await evaluatorsFor(upd.chat) + return await upd.answer({ + text: t("error-admin-button"), + }) + } - const settings = await getPeerSettings(upd.user) + const settings = await getPeerSettings(upd.chat) const valueIndex = +upd.match.value const value = getSettingValues(upd.match.setting)[valueIndex] if (value === customValue) { - const { e, t } = await evaluatorsFor(upd.user) + const { e, t } = await evaluatorsFor(upd.chat) const { text: _, ...props } = settingEditMessage(e, settings, upd.match.setting) await upd.editMessage({ text: t("setting-custom"), ...props }) await state.enter(settingInputScene, { with: { setting: upd.match.setting } }) return } - const newSettings = await updateSetting(upd.match.setting, value, upd.user.id) + const newSettings = await updateSetting(upd.match.setting, value, upd.chat.id) // We're getting evaluator AFTER the possible locale update - const { e } = await evaluatorsFor(upd.user) + const { e } = await evaluatorsFor(upd.chat) await upd.editMessage(settingEditMessage(e, newSettings ?? settings, upd.match.setting)) }) settingInputScene.onNewMessage(async (upd, state) => { + if (upd.sender.type !== "user" || !await isAdmin(upd.client, upd.chat, upd.sender)) { + return + } + const stateData = await state.get() if (!stateData) { await state.exit() return } - const { t } = await evaluatorsFor(upd.sender) - await updateSetting(stateData.setting, upd.text, upd.sender.id) + const { t } = await evaluatorsFor(upd.chat) + await updateSetting(stateData.setting, upd.text, upd.chat.id) await upd.replyText(t("setting-saved")) await state.exit() diff --git a/src/telegram/bot/start.ts b/src/telegram/bot/start.ts index 7f5e387..4538a5e 100644 --- a/src/telegram/bot/start.ts +++ b/src/telegram/bot/start.ts @@ -3,6 +3,6 @@ import { translatorFor } from "@/telegram/helpers/i18n" export const startDp = Dispatcher.child() startDp.onNewMessage(filters.start, async (msg) => { - const t = await translatorFor(msg.sender) + const t = await translatorFor(msg.chat) await msg.replyText(t("start")) }) diff --git a/src/telegram/bot/stats.ts b/src/telegram/bot/stats.ts index 211b5dc..015fe59 100644 --- a/src/telegram/bot/stats.ts +++ b/src/telegram/bot/stats.ts @@ -6,13 +6,13 @@ import { evaluatorsFor } from "@/telegram/helpers/text" export const statsDp = Dispatcher.child() statsDp.onNewMessage(filters.command("stats"), async (msg) => { - const { t } = await evaluatorsFor(msg.sender) + const { t } = await evaluatorsFor(msg.chat) const count = await getDownloadStats() await msg.replyText(t("stats-global", { count })) }) statsDp.onNewMessage(filters.command("mystats"), async (msg) => { - const { t } = await evaluatorsFor(msg.sender) + const { t } = await evaluatorsFor(msg.chat) const id = msg.sender.id const count = await getDownloadStats(id) await msg.replyText(t("stats-personal", { count })) diff --git a/src/telegram/helpers/handler.ts b/src/telegram/helpers/handler.ts index 1309d5d..52e8f09 100644 --- a/src/telegram/helpers/handler.ts +++ b/src/telegram/helpers/handler.ts @@ -1,4 +1,4 @@ -import type { InputMediaLike, Peer } from "@mtcute/node" +import type { InputMediaLike } from "@mtcute/node" import type { GeneralTrack, ImageTrack, VideoTrack } from "mediainfo.js" import { CallbackDataBuilder } from "@mtcute/dispatcher" @@ -8,13 +8,13 @@ import type { ApiServer, CobaltDownloadParams } from "@/core/data/cobalt" import type { DownloadedMediaContent } from "@/core/data/cobalt/tunnel" import type { MediaRequest } from "@/core/data/request" import { finishRequest, outputOptions } from "@/core/data/request" +import type { Settings } from "@/core/data/settings" import type { Result } from "@/core/utils/result" import { error, ok } from "@/core/utils/result" import type { Text } from "@/core/utils/text" import { translatable } from "@/core/utils/text" import { urlWithAuthSchema } from "@/core/utils/url" import { env } from "@/telegram/helpers/env" -import { getPeerSettings } from "@/telegram/helpers/settings" export const OutputButton = new CallbackDataBuilder("dl", "output", "request") export const getOutputSelectionMessage = (requestId: string) => ({ @@ -112,10 +112,9 @@ function getApiEndpoints(override: string | null): Result { ) } -export async function handleMediaDownload(outputType: string, request: MediaRequest | undefined, peer: Peer): Promise> { +export async function handleMediaDownload(outputType: string, request: MediaRequest | undefined, settings: Settings): Promise> { if (!request) return error(translatable("error-request-not-found")) - const settings = await getPeerSettings(peer) const endpoints = getApiEndpoints(settings.instanceOverride) if (!endpoints.success) return endpoints From 3aeaf37b201563da1acdaa1d86abe88ce7bad84b Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 18:02:04 +0300 Subject: [PATCH 02/11] fix: Update picker note to match actual behavior --- locales/en.ftl | 2 +- locales/ru.ftl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/en.ftl b/locales/en.ftl index 43a6dd2..74afa50 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -15,7 +15,7 @@ error-invalid-url = looks like i dont recognise the link you sent... maybe the s error-media-unavailable = i found your media, but couldn't download it. maybe its private, age restricted or region locked. error-unknown = oops, an internal error happened. i reported it to my developer, so they'll fix it! -note-picker = your link contained multiple media files, so i sent them to you via pms +note-picker = your link contained multiple media files, so i sent them to you seperately or via pms download-title = download from provided url type-select-title = select download type (。 · ᎑ ·。) diff --git a/locales/ru.ftl b/locales/ru.ftl index 6de47c8..169831f 100644 --- a/locales/ru.ftl +++ b/locales/ru.ftl @@ -15,7 +15,7 @@ error-invalid-url = кажется у меня не получается рас error-media-unavailable = я нашёл нужный файл, но не смог его скачать. возможно на нём ограничения по региону, возрасту или приватности. error-unknown = ой, произошла внутреняя ошибка. я сообщил об этом моим разработчикам, чтобы они исправили! -note-picker = по твоей ссылке я нашёл несколько медиа файлов, поэтому я отправил тебе их в лс +note-picker = по твоей ссылке я нашёл несколько медиа файлов, поэтому я отправил тебе отдельно или их в лс download-title = скачать по ссылке type-select-title = выбери тип загрузки (。 · ᎑ ·。) From dbc267c75655666bc66ab22289ea5f8cd64e513e Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 18:16:06 +0300 Subject: [PATCH 03/11] feat: Implement multi-link download --- src/telegram/bot/download.ts | 61 +++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index b04d694..79af9ec 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -28,37 +28,40 @@ downloadDp.onNewMessage(async (msg) => { return } - const urlEntity = msg.entities.find(e => e.is("text_link") || e.is("url")) - const extractedUrl = urlEntity && (urlEntity.is("text_link") ? urlEntity.params.url : urlEntity.text) - const req = await createRequest(extractedUrl || msg.text, msg.sender.id) + const urlEntities = msg.entities.filter(e => e.is("text_link") || e.is("url")) + const extractedUrls = urlEntities.map(e => (e.is("text_link") ? e.params.url : e.text)) + const urls = extractedUrls.length ? extractedUrls : [msg.text] + for (const url of urls) { + const req = await createRequest(url, msg.sender.id) + + if (!req.success) { + if (msg.chat.type === "user") + await msg.replyText(t("error", { message: e(req.error) })) + return + } - if (!req.success) { - if (msg.chat.type === "user") - await msg.replyText(t("error", { message: e(req.error) })) - return - } + const selectMsg = getOutputSelectionMessage(req.result.id) + const reply = await msg.replyText(e(selectMsg.caption), { + replyMarkup: BotKeyboard.inline([ + selectMsg.options.map(o => BotKeyboard.callback( + e(o.name), + o.key, + )), + ]), + }) - const selectMsg = getOutputSelectionMessage(req.result.id) - const reply = await msg.replyText(e(selectMsg.caption), { - replyMarkup: BotKeyboard.inline([ - selectMsg.options.map(o => BotKeyboard.callback( - e(o.name), - o.key, - )), - ]), - }) - - const settings = await getPeerSettings(msg.chat) - if (settings.preferredOutput) { - await onOutputSelected( - settings.preferredOutput, - req.result, - args => msg.client.editMessage({ ...args, message: reply }), - { e, t }, - settings, - ({ medias }) => msg.replyMediaGroup(medias), - msg.sender, - ) + const settings = await getPeerSettings(msg.chat) + if (settings.preferredOutput) { + await onOutputSelected( + settings.preferredOutput, + req.result, + args => msg.client.editMessage({ ...args, message: reply }), + { e, t }, + settings, + ({ medias }) => msg.replyMediaGroup(medias), + msg.sender, + ) + } } }) From 780b0f7156315672465429c9a10be52c8cdb9521 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 18:32:11 +0300 Subject: [PATCH 04/11] feat: Make error messages automatically deleted after a timeout in group chats --- src/telegram/bot/download.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index 79af9ec..91c8be8 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -20,6 +20,8 @@ import { evaluatorsFor } from "@/telegram/helpers/text" export const downloadDp = Dispatcher.child() +const errorDeleteDelay = 30 * 1000 + downloadDp.onNewMessage(async (msg) => { const { e, t } = await evaluatorsFor(msg.chat) @@ -52,7 +54,7 @@ downloadDp.onNewMessage(async (msg) => { const settings = await getPeerSettings(msg.chat) if (settings.preferredOutput) { - await onOutputSelected( + const res = await onOutputSelected( settings.preferredOutput, req.result, args => msg.client.editMessage({ ...args, message: reply }), @@ -61,6 +63,8 @@ downloadDp.onNewMessage(async (msg) => { ({ medias }) => msg.replyMediaGroup(medias), msg.sender, ) + if (!res && msg.chat.type !== "user") + setTimeout(() => msg.client.deleteMessages([reply]), errorDeleteDelay) } } }) @@ -129,7 +133,7 @@ downloadDp.onAnyCallbackQuery(OutputButton.filter(), async (upd) => { }) } - await onOutputSelected( + const res = await onOutputSelected( outputType, request, args => upd.editMessage(args), @@ -138,6 +142,8 @@ downloadDp.onAnyCallbackQuery(OutputButton.filter(), async (upd) => { ({ medias }) => upd.client.sendMediaGroup(peer.id, medias), upd.user, ) + if (!res && rawUpd._name === "callback_query" && rawUpd.chat.type !== "user") + setTimeout(() => upd.client.deleteMessagesById(rawUpd.chat.id, [rawUpd.messageId]), errorDeleteDelay) }) downloadDp.onChosenInlineResult(async (upd) => { @@ -172,8 +178,8 @@ async function onOutputSelected( const res = await handleMediaDownload(outputType, request, settings) if (!res.success) { const errorMessage = t("error", { message: e(res.error) }) - await editMessage({ text: leaveSourceLink ? `${errorMessage}\n\n${request?.url}` : errorMessage }) - return + await editMessage({ text: settings.preferredAttribution ? `${errorMessage}\n\n${request?.url}` : errorMessage }) + return false } await editMessage({ text: t("uploading-title") }) @@ -190,4 +196,6 @@ async function onOutputSelected( incrementDownloadCount(sender.id) .catch(() => { /* noop */ }) + + return true } From f26f682689ae8836f9a2b2d9d4e889acb6c2cbe3 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 18:45:53 +0300 Subject: [PATCH 05/11] feat: Add group join message --- locales/en.ftl | 4 ++++ locales/ru.ftl | 4 ++++ src/telegram/bot/start.ts | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/locales/en.ftl b/locales/en.ftl index 74afa50..2ff7703 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -1,6 +1,10 @@ start = hii! just send me a link and i'll download it. (ᵔᵕᵔ)◜ details: /info +join = + hii! (ᵔᵕᵔ)◜ + i will download all links i'll find in this chat. + all error messages will be deleted within 30 seconds to not annoy you. error-title = error error = { error-title }: { $message } { $error-emoticon } diff --git a/locales/ru.ftl b/locales/ru.ftl index 169831f..9090261 100644 --- a/locales/ru.ftl +++ b/locales/ru.ftl @@ -1,6 +1,10 @@ start = привет! просто отправь мне ссылку и я её скачаю. (ᵔᵕᵔ)◜ подробнее: /info +join = + привет! (ᵔᵕᵔ)◜ + я буду скачивать все ссылки, которые я найду в этом чате. + все сообщения об ошибках будут удаляться в течении 30 секунд, чтобы не мешать вам. error-title = ошибка error = { error-title }: { $message } { $error-emoticon } diff --git a/src/telegram/bot/start.ts b/src/telegram/bot/start.ts index 4538a5e..9013685 100644 --- a/src/telegram/bot/start.ts +++ b/src/telegram/bot/start.ts @@ -6,3 +6,8 @@ startDp.onNewMessage(filters.start, async (msg) => { const t = await translatorFor(msg.chat) await msg.replyText(t("start")) }) + +startDp.onChatMemberUpdate(filters.and(filters.chatMemberSelf, filters.chatMember("added")), async (upd) => { + const t = await translatorFor(upd.chat) + await upd.client.sendText(upd.chat, t("join")) +}) From c5c9a0c2eeea020e85e94cc734f0990e0c8b1c92 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 9 Jan 2026 18:46:53 +0300 Subject: [PATCH 06/11] fix: Force auto dowload in groups --- src/telegram/bot/download.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index 91c8be8..091565e 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -53,9 +53,9 @@ downloadDp.onNewMessage(async (msg) => { }) const settings = await getPeerSettings(msg.chat) - if (settings.preferredOutput) { + if (settings.preferredOutput || msg.chat.type !== "user") { const res = await onOutputSelected( - settings.preferredOutput, + settings.preferredOutput || "auto", req.result, args => msg.client.editMessage({ ...args, message: reply }), { e, t }, From 3d72d5f7abe0125b944a592ca23621c019fd6edd Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Sun, 1 Mar 2026 18:08:49 +0300 Subject: [PATCH 07/11] fix: Undo merge of two edit calls so downloaded media captions to work again --- src/telegram/bot/download.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index 091565e..c15933f 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -191,7 +191,9 @@ async function onOutputSelected( await sendGroup({ medias: chunk }) } } else { - await editMessage({ media: res.result[0], text: (!!settings.preferredAttribution && request?.url) || "" }) + // FIXME: Merge two edit calls + await editMessage({ media: res.result[0] }) + await editMessage({ text: (!!settings.preferredAttribution && request?.url) || "" }) } incrementDownloadCount(sender.id) From 582d8f569a3960f0cf45164b2191b6c0eda3a152 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Sat, 7 Mar 2026 02:15:23 +0300 Subject: [PATCH 08/11] fix: Improve URL parsing with stricter validation and protocol-relative support Fixes #18 --- src/core/utils/url.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/utils/url.ts b/src/core/utils/url.ts index 2fd0f78..88372e7 100644 --- a/src/core/utils/url.ts +++ b/src/core/utils/url.ts @@ -38,11 +38,12 @@ export function tryParseUrl(url: string) { if (originalParsed.success) return originalParsed.data - const domain = url.split("/")[0] - if (!domain.includes(".") || domain.includes(" ") || domain.includes(":")) + const protocollessUrl = url.startsWith("//") ? url.slice(2) : url + const domain = protocollessUrl.split("/")[0] + if (!domain.includes(".") || domain.includes(" ") || domain.endsWith(".") || domain.startsWith(".")) return null - const withHttpsParsed = mediaUrlSchema.safeParse(`https://${url}`) + const withHttpsParsed = mediaUrlSchema.safeParse(`https://${protocollessUrl}`) if (withHttpsParsed.success) return withHttpsParsed.data From 9e82eec84fbf954dbae1d21b7d7eeeff5abb7aa5 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Mon, 30 Mar 2026 18:52:51 +0300 Subject: [PATCH 09/11] feat: Improve channel support --- src/telegram/bot/download.ts | 46 +++++++++++++++++++++++++++++------- src/telegram/bot/start.ts | 2 ++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index c15933f..bb5ebbf 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -23,21 +23,52 @@ export const downloadDp = Dispatcher.child() const errorDeleteDelay = 30 * 1000 downloadDp.onNewMessage(async (msg) => { - const { e, t } = await evaluatorsFor(msg.chat) - if (msg.text === "meow") { await msg.replyText("meow :з") return } + const isGroupChat = msg.chat.type === "chat" + const isChannel = isGroupChat && msg.chat.chatType === "channel" + + const settings = await getPeerSettings(msg.chat) + const { e, t } = await evaluatorsFor(msg.chat) + const urlEntities = msg.entities.filter(e => e.is("text_link") || e.is("url")) const extractedUrls = urlEntities.map(e => (e.is("text_link") ? e.params.url : e.text)) - const urls = extractedUrls.length ? extractedUrls : [msg.text] + const urls = isGroupChat ? extractedUrls : (extractedUrls.length ? extractedUrls : [msg.text]) + + if (isChannel) { + const [url] = urls + if (!url || msg.media) + return + + const req = await createRequest(url, msg.sender.id) + if (!req.success) + return + + const originalText = msg.text + const res = await onOutputSelected( + settings.preferredOutput || "auto", + req.result, + args => msg.client.editMessage({ ...args, message: msg }), + { e, t }, + settings, + ({ medias }) => msg.replyMediaGroup(medias), + msg.sender, + ) + + if (!res) + msg.client.editMessage({ text: originalText, message: msg }) + + return + } + for (const url of urls) { const req = await createRequest(url, msg.sender.id) if (!req.success) { - if (msg.chat.type === "user") + if (!isGroupChat) await msg.replyText(t("error", { message: e(req.error) })) return } @@ -52,8 +83,7 @@ downloadDp.onNewMessage(async (msg) => { ]), }) - const settings = await getPeerSettings(msg.chat) - if (settings.preferredOutput || msg.chat.type !== "user") { + if (settings.preferredOutput || isGroupChat) { const res = await onOutputSelected( settings.preferredOutput || "auto", req.result, @@ -63,7 +93,7 @@ downloadDp.onNewMessage(async (msg) => { ({ medias }) => msg.replyMediaGroup(medias), msg.sender, ) - if (!res && msg.chat.type !== "user") + if (!res && isGroupChat) setTimeout(() => msg.client.deleteMessages([reply]), errorDeleteDelay) } } @@ -199,5 +229,5 @@ async function onOutputSelected( incrementDownloadCount(sender.id) .catch(() => { /* noop */ }) - return true + return res.result.length === 1 } diff --git a/src/telegram/bot/start.ts b/src/telegram/bot/start.ts index 9013685..8984e65 100644 --- a/src/telegram/bot/start.ts +++ b/src/telegram/bot/start.ts @@ -9,5 +9,7 @@ startDp.onNewMessage(filters.start, async (msg) => { startDp.onChatMemberUpdate(filters.and(filters.chatMemberSelf, filters.chatMember("added")), async (upd) => { const t = await translatorFor(upd.chat) + if (upd.chat.chatType === "channel") + return await upd.client.sendText(upd.chat, t("join")) }) From 6611d63487e4c45cf2ed1b3daaa844c256620eb5 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 8 May 2026 20:37:24 +0300 Subject: [PATCH 10/11] fix: Update strings for group chat & channel support --- locales/en.ftl | 4 ++-- locales/ru.ftl | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/locales/en.ftl b/locales/en.ftl index 2ff7703..2baacfa 100644 --- a/locales/en.ftl +++ b/locales/en.ftl @@ -4,7 +4,7 @@ start = join = hii! (ᵔᵕᵔ)◜ i will download all links i'll find in this chat. - all error messages will be deleted within 30 seconds to not annoy you. + details: /info error-title = error error = { error-title }: { $message } { $error-emoticon } @@ -78,7 +78,7 @@ stats-global = i helped with downloading { $count } times! (˶ᵔ ᵕ ᵔ˶) info-open = about me info = i can download videos, photos and music from any service supported by cobalt! - you can use me right here in pms or in any chat via inline mode. + you can use me right here in pms & in any chat via inline mode or add me to your favorite chat or channel. running { $name }@{ $version } sources: { $repository } diff --git a/locales/ru.ftl b/locales/ru.ftl index 9090261..5839431 100644 --- a/locales/ru.ftl +++ b/locales/ru.ftl @@ -4,7 +4,7 @@ start = join = привет! (ᵔᵕᵔ)◜ я буду скачивать все ссылки, которые я найду в этом чате. - все сообщения об ошибках будут удаляться в течении 30 секунд, чтобы не мешать вам. + подробнее: /info error-title = ошибка error = { error-title }: { $message } { $error-emoticon } @@ -64,7 +64,7 @@ stats-global = я помог с загрузкой { $count } раз! (˶ᵔ ᵕ info-open = обо мне info = я умею скачивать видео, фото и музыку из любих сервисов, которые доступны в cobalt! - меня можно использовать прямо тут в лс или в любом чате через инлайн. + меня можно использовать прямо тут в лс и в любом чате через инлайн, или добавить в любимую группу или канал. выполняется { $name }@{ $version } сурсы: { $repository } From db31511610a8bdeb58998dae212a4fe867b67432 Mon Sep 17 00:00:00 2001 From: Damir Modyarov Date: Fri, 8 May 2026 21:07:15 +0300 Subject: [PATCH 11/11] feat: Implement deferred replies for group chats --- src/telegram/bot/download.ts | 9 ++++---- src/telegram/helpers/sent.ts | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/telegram/helpers/sent.ts diff --git a/src/telegram/bot/download.ts b/src/telegram/bot/download.ts index bb5ebbf..c9fab5e 100644 --- a/src/telegram/bot/download.ts +++ b/src/telegram/bot/download.ts @@ -14,6 +14,7 @@ import { handleMediaDownload, OutputButton, } from "@/telegram/helpers/handler" +import { deferredReply, replyText } from "@/telegram/helpers/sent" import { getPeerSettings } from "@/telegram/helpers/settings" import type { Evaluators } from "@/telegram/helpers/text" import { evaluatorsFor } from "@/telegram/helpers/text" @@ -74,7 +75,7 @@ downloadDp.onNewMessage(async (msg) => { } const selectMsg = getOutputSelectionMessage(req.result.id) - const reply = await msg.replyText(e(selectMsg.caption), { + const reply = await (isGroupChat ? deferredReply : replyText)(msg, e(selectMsg.caption), { replyMarkup: BotKeyboard.inline([ selectMsg.options.map(o => BotKeyboard.callback( e(o.name), @@ -87,14 +88,14 @@ downloadDp.onNewMessage(async (msg) => { const res = await onOutputSelected( settings.preferredOutput || "auto", req.result, - args => msg.client.editMessage({ ...args, message: reply }), + args => reply.edit(args), { e, t }, settings, ({ medias }) => msg.replyMediaGroup(medias), msg.sender, ) - if (!res && isGroupChat) - setTimeout(() => msg.client.deleteMessages([reply]), errorDeleteDelay) + if (res && isGroupChat) + await reply.flush() } } }) diff --git a/src/telegram/helpers/sent.ts b/src/telegram/helpers/sent.ts new file mode 100644 index 0000000..6542f5b --- /dev/null +++ b/src/telegram/helpers/sent.ts @@ -0,0 +1,40 @@ +import type { MessageContext } from "@mtcute/dispatcher" +import type { InputMediaLike } from "@mtcute/node" + +export type SentMessage = { + edit: (edit: { text?: string, media?: InputMediaLike }) => Promise, + flush: () => Promise, +} + +export async function replyText(ctx: MessageContext, ...params: Parameters): Promise { + const reply = await ctx.replyText(...params) + return { + edit: args => ctx.client.editMessage({ ...args, message: reply }), + flush: () => Promise.resolve(), + } +} + +export async function deferredReply(ctx: MessageContext, ...params: Parameters): Promise { + let sent = false + let media: InputMediaLike | null = null + let text = params[0] + const config: Parameters[1] = params[1] + return { + edit: async (edit) => { + if (edit.media) + media = edit.media + else + text = edit.text ?? "" + }, + flush: async () => { + console.log("flush!") + if (sent) + throw new Error("Tried to send a deferred message that was already sent") + sent = true + if (media) + await ctx.replyMedia(media, { caption: text }) + else + await ctx.replyText(text, config) + }, + } +}