Skip to content
Draft
9 changes: 7 additions & 2 deletions locales/en.ftl
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
start = hii! just send me a link and i'll download it. (ᵔᵕᵔ)◜
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 }
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...
error-invalid-url = looks like i dont recognise the link you sent... maybe the service isn't supported or you pasted it wrong
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 (。 · ᎑ ·。)
Expand Down Expand Up @@ -71,4 +76,4 @@ stats-global = i helped with downloading { $count } times! (˶ᵔ ᵕ ᵔ˶)
info =
running { $name }@{ $version }
sources: { $repository }
report bugs: { $bugs }
report bugs: { $bugs }
9 changes: 7 additions & 2 deletions locales/ru.ftl
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
start = привет! просто отправь мне ссылку и я её скачаю. (ᵔᵕᵔ)◜
join =
привет! (ᵔᵕᵔ)◜
я буду скачивать все ссылки, которые я найду в этом чате.
все сообщения об ошибках будут удаляться в течении 30 секунд, чтобы не мешать вам.

error-title = ошибка
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 = не удалось подключиться к серверу, наверное он лежит...
error-invalid-url = кажется у меня не получается распознать отправленную сылку... возможно она неправильно вставлена или этот сервис не поддерживается
error-media-unavailable = я нашёл нужный файл, но не смог его скачать. возможно на нём ограничения по региону, возрасту или приватности.
error-unknown = ой, произошла внутреняя ошибка. я сообщил об этом моим разработчикам, чтобы они исправили!

note-picker = по твоей ссылке я нашёл несколько медиа файлов, поэтому я отправил тебе их в лс
note-picker = по твоей ссылке я нашёл несколько медиа файлов, поэтому я отправил тебе отдельно или их в лс

download-title = скачать по ссылке
type-select-title = выбери тип загрузки (。 · ᎑ ·。)
Expand Down Expand Up @@ -57,4 +62,4 @@ stats-global = я помог с загрузкой { $count } раз! (˶ᵔ ᵕ
info =
выполняется { $name }@{ $version }
сурсы: { $repository }
репорт багов: { $bugs }
репорт багов: { $bugs }
7 changes: 4 additions & 3 deletions src/core/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
132 changes: 91 additions & 41 deletions src/telegram/bot/download.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,44 +20,82 @@ 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)
const errorDeleteDelay = 30 * 1000

downloadDp.onNewMessage(async (msg) => {
if (msg.text === "meow") {
await msg.replyText("meow :з")
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 isGroupChat = msg.chat.type === "chat"
const isChannel = isGroupChat && msg.chat.chatType === "channel"

if (!req.success) {
await msg.replyText(t("error", { message: e(req.error) }))
return
}
const settings = await getPeerSettings(msg.chat)
const { e, t } = await evaluatorsFor(msg.chat)

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.sender)
if (settings.preferredOutput) {
await onOutputSelected(
settings.preferredOutput,
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 = 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: reply }),
args => msg.client.editMessage({ ...args, message: msg }),
{ e, t },
msg.sender,
!!settings.preferredAttribution,
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 (!isGroupChat)
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,
)),
]),
})

if (settings.preferredOutput || isGroupChat) {
const res = await onOutputSelected(
settings.preferredOutput || "auto",
req.result,
args => msg.client.editMessage({ ...args, message: reply }),
{ e, t },
settings,
({ medias }) => msg.replyMediaGroup(medias),
msg.sender,
)
if (!res && isGroupChat)
setTimeout(() => msg.client.deleteMessages([reply]), errorDeleteDelay)
}
}
})

Expand Down Expand Up @@ -102,8 +142,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)
Expand All @@ -113,15 +158,17 @@ downloadDp.onAnyCallbackQuery(OutputButton.filter(), async (upd) => {
})
}

await onOutputSelected(
const res = await onOutputSelected(
outputType,
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),
)
if (!res && rawUpd._name === "callback_query" && rawUpd.chat.type !== "user")
setTimeout(() => upd.client.deleteMessagesById(rawUpd.chat.id, [rawUpd.messageId]), errorDeleteDelay)
})

downloadDp.onChosenInlineResult(async (upd) => {
Expand All @@ -136,9 +183,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,
)
}
})
Expand All @@ -148,16 +195,16 @@ async function onOutputSelected(
request: MediaRequest | undefined,
editMessage: (edit: { text?: string, media?: InputMediaLike }) => Promise<unknown>,
{ t, e }: Evaluators,
peer: Peer,
leaveSourceLink: boolean,
settings: Settings,
sendGroup: (send: { medias: InputMediaLike[] }) => Promise<unknown>,
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 })
return
await editMessage({ text: settings.preferredAttribution ? `${errorMessage}\n\n${request?.url}` : errorMessage })
return false
}

await editMessage({ text: t("uploading-title") })
Expand All @@ -169,10 +216,13 @@ async function onOutputSelected(
await sendGroup({ medias: chunk })
}
} else {
// FIXME: Merge two edit calls
await editMessage({ media: res.result[0] })
await editMessage({ text: (leaveSourceLink && request?.url) || "" })
await editMessage({ text: (!!settings.preferredAttribution && request?.url) || "" })
}

incrementDownloadCount(peer.id)
incrementDownloadCount(sender.id)
.catch(() => { /* noop */ })

return res.result.length === 1
}
2 changes: 1 addition & 1 deletion src/telegram/bot/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ import { evaluatorsFor } from "@/telegram/helpers/text"
export const infoDp = Dispatcher.child()

settingsDp.onNewMessage(filters.command("info"), async (msg) => {
const { t } = await evaluatorsFor(msg.sender)
const { t } = await evaluatorsFor(msg.chat)
await msg.replyText(t("info", { bugs, name, repository, version }))
})
47 changes: 35 additions & 12 deletions src/telegram/bot/settings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Peer, TelegramClient, User } from "@mtcute/node"
import { Dispatcher, filters, PropagationAction } from "@mtcute/dispatcher"
import { BotKeyboard } from "@mtcute/node"

Expand Down Expand Up @@ -34,6 +35,13 @@ function settingsMessage(e: TextEvaluator, settings: Settings) {
}
}

async function isAdmin(client: Pick<TelegramClient, "getChatMember">, 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 {
Expand All @@ -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
Expand All @@ -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()
Expand Down
Loading
Loading