diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 8282d1b..5a9efeb 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -13,10 +13,11 @@ import { trackTitle, } from "./format.js"; -/** What we need back from a sent message to hang reactions on it. */ +/** What we need back from a sent message to work with it afterwards. */ export interface SentMessage { id: string; react(emoji: string): Promise; + edit(content: string): Promise; } export interface CommandContext { @@ -170,18 +171,27 @@ export const commands: Command[] = [ description: "Найти треки и выбрать реакцией", async run(ctx) { if (!ctx.rest) throw new UserFacingError("Укажите поисковый запрос"); + // Searching several sources can take tens of seconds through a proxy, and + // the command message is already deleted by then — without a placeholder + // the chat sits silent and the bot looks broken. + const placeholder = await ctx.reply(`🔎 Ищу **${ctx.rest}**…`); + const tracks = (await ctx.manager.search(ctx.rest, ctx.actor, CHAT_SEARCH_LIMIT)).slice( 0, CHAT_SEARCH_LIMIT, ); if (tracks.length === 0) { - await ctx.reply(`🔎 ${NOTHING_FOUND}`); + const missing = `🔎 ${NOTHING_FOUND}`; + if (placeholder) await placeholder.edit(missing); + else await ctx.reply(missing); return; } const lines = tracks.map((track, index) => trackLine(track, { index: index + 1 })); - const sent = await ctx.reply(`🔎 **${ctx.rest}**\n${lines.join("\n")}`); + const text = `🔎 **${ctx.rest}**\n${lines.join("\n")}`; + const sent = placeholder ?? (await ctx.reply(text)); if (!sent) return; + if (placeholder) await placeholder.edit(text); rememberSearch(ctx, sent.id, tracks); for (const emoji of CHOICE_EMOJI.slice(0, tracks.length)) { diff --git a/src/bot/index.ts b/src/bot/index.ts index 332d33f..09fd01c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -82,7 +82,17 @@ async function handleMessage(manager: MusicManager, message: Message): Promise message.channel?.sendMessage(text); + const reply = async (text: string) => { + const sent = await message.channel?.sendMessage(text); + if (!sent) return undefined; + return { + id: sent.id, + react: (emoji: string) => sent.react(emoji), + edit: async (content: string) => { + await sent.edit({ content }); + }, + }; + }; if (!serverId) { await reply("Команды работают только внутри сервера."); diff --git a/src/sources/index.ts b/src/sources/index.ts index 083e521..7201a93 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -135,13 +135,32 @@ export async function searchTracks( } const [youtube, soundcloud, localHits] = await Promise.all([ - ytdlp.search(text, "youtube", limit, requestedBy).catch(() => []), - ytdlp.search(text, "soundcloud", limit, requestedBy).catch(() => []), + withDeadline(ytdlp.search(text, "youtube", limit, requestedBy), "youtube"), + withDeadline(ytdlp.search(text, "soundcloud", limit, requestedBy), "soundcloud"), local.isEnabled() ? local.search(text, 3, requestedBy).catch(() => []) : Promise.resolve([]), ]); return [...localHits, ...interleave(youtube, soundcloud)].slice(0, limit); } +/** + * One slow source should not hold up the whole answer: searching several at once + * means waiting for the slowest, and behind a proxy that can be tens of seconds. + */ +const SOURCE_DEADLINE_MS = 12_000; + +async function withDeadline(search: Promise, source: string): Promise { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => { + log.warn({ source }, "search took too long, answering without it"); + resolve([]); + }, SOURCE_DEADLINE_MS); + }); + const tracks = await Promise.race([search.catch(() => []), deadline]); + if (timer) clearTimeout(timer); + return tracks; +} + /** * YouTube silently returns nothing for queries its restricted mode dislikes — * same query, same words, results in a browser but an empty list over the API.