/** * Telegram queue menu UI helpers * Zones: telegram ui, queue controls, menu composition * Owns queue-menu rendering, queue item callbacks, and queue-menu runtime adapters while core queue mechanics stay in queue */ import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts"; import type { TelegramModelMenuState } from "./menu.ts"; import type { MenuModel } from "./model.ts"; import * as Queue from "./queue.ts"; // --- Queue Menu --- const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600; const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\nโ€ฆ [truncated]"; const EMPTY_QUEUE_REFRESH_TITLES = [ "โŒ› Queue is still empty.", "๐Ÿซ™ Still nothing in queue.", "๐Ÿƒ Queue remains empty.", "๐Ÿ•ณ Nothing queued yet.", "๐Ÿฆ— Queue crickets continue.", "๐ŸŒ™ Queue is peacefully idle.", "๐Ÿง˜ Nothing waiting. Very zen.", "๐Ÿช Queue orbit is clear.", "๐Ÿงบ Basket is empty.", "๐Ÿ”ญ No prompts on the horizon.", "๐Ÿซง Queue bubbles: none.", "๐Ÿ›ธ No queued signals detected.", ] as const; type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup; interface TelegramQueueMenuItem { chatId: number; replyToMessageId: number; queuePosition: number; isPriority: boolean; priorityEmoji?: string; reactionSuppressionEmoji?: string; hasAttachments: boolean; statusSummary: string; promptText: string; } function getTelegramQueueItemPromptText( item: Queue.TelegramQueueItem, ): string { if (item.kind !== "prompt") return item.statusSummary; return ( item.content .filter((block) => block.type === "text") .map((block) => block.text) .join("\n") .trim() || item.statusSummary ); } function toTelegramQueueMenuItems( items: readonly Queue.TelegramQueueItem[], ): TelegramQueueMenuItem[] { return items.map((item, index) => { return { chatId: item.chatId, replyToMessageId: item.replyToMessageId, queuePosition: index + 1, isPriority: item.queueLane === "priority", priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined, reactionSuppressionEmoji: item.kind === "prompt" ? item.reactionSuppressionEmoji : undefined, hasAttachments: item.kind === "prompt" && item.queuedAttachments.length > 0, statusSummary: item.statusSummary, promptText: getTelegramQueueItemPromptText(item), }; }); } function formatSkippedTelegramQueuePosition(position: number): string { return Array.from(String(position), (char) => `${char}\u0335`).join(""); } function buildTelegramQueueMenuReplyMarkup( items: readonly TelegramQueueMenuItem[], emptyRefreshIndex = 0, ): TelegramQueueMenuReplyMarkup { const backRow = [{ text: "โฌ†๏ธ Main menu", callback_data: "menu:back" }]; const nextEmptyRefreshIndex = (emptyRefreshIndex + 1) % EMPTY_QUEUE_REFRESH_TITLES.length; const refreshData = items.length === 0 ? `queue:refresh:${nextEmptyRefreshIndex}` : "queue:refresh"; const refreshRow = [{ text: "๐ŸŒ€ Refresh", callback_data: refreshData }]; if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] }; const rows = items.map((item) => { const prefix = item.reactionSuppressionEmoji ? `${item.reactionSuppressionEmoji} ` : item.isPriority ? `${item.priorityEmoji ?? "โšก"} ` : item.hasAttachments ? "๐Ÿ“Ž " : ""; const position = item.reactionSuppressionEmoji ? formatSkippedTelegramQueuePosition(item.queuePosition) : String(item.queuePosition); const ordinalSeparator = item.reactionSuppressionEmoji ? "\u200A" : ""; const label = `${position}${ordinalSeparator}. ${prefix}${item.statusSummary}`; return [ { text: label, callback_data: `queue:pick:${item.chatId}:${item.replyToMessageId}`, }, ]; }); return { inline_keyboard: [backRow, refreshRow, ...rows] }; } function findTelegramQueueItem( items: readonly Queue.TelegramQueueItem[], chatId: number, replyToMessageId: number, ): Queue.TelegramQueueItem | undefined { return items.find((item) => { return item.chatId === chatId && item.replyToMessageId === replyToMessageId; }); } function findTelegramQueueMenuItem( items: readonly TelegramQueueMenuItem[], chatId: number, replyToMessageId: number, ): TelegramQueueMenuItem | undefined { return items.find((item) => { return item.chatId === chatId && item.replyToMessageId === replyToMessageId; }); } function escapeTelegramQueueMenuHtmlChar(char: string): string { if (char === "&") return "&"; if (char === "<") return "<"; if (char === ">") return ">"; return char; } function escapeTelegramQueueMenuHtml(text: string): string { return Array.from(text).map(escapeTelegramQueueMenuHtmlChar).join(""); } function escapeTelegramQueueMenuHtmlPreview(text: string): string { const suffix = escapeTelegramQueueMenuHtml( QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX, ); let escaped = ""; let truncated = false; for (const char of text) { const next = escapeTelegramQueueMenuHtmlChar(char); if ( escaped.length + next.length + suffix.length > QUEUE_ITEM_PROMPT_HTML_LIMIT ) { truncated = true; break; } escaped += next; } return truncated ? escaped + suffix : escaped; } function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string { const badge = item.reactionSuppressionEmoji ? ` ${item.reactionSuppressionEmoji}` : item.isPriority ? ` ${item.priorityEmoji ?? "โšก"}` : ""; const position = item.reactionSuppressionEmoji ? `${item.queuePosition}.` : `${item.queuePosition}.`; const heading = `${position}${badge}`; const preview = `
${escapeTelegramQueueMenuHtmlPreview(item.promptText)}
`; return `${heading}\n${preview}`; } function buildTelegramQueueItemSubmenuReplyMarkup( chatId: number, replyToMessageId: number, isPriority: boolean, isSkipped: boolean, ): TelegramQueueMenuReplyMarkup { return { inline_keyboard: [ [{ text: "โฌ†๏ธ Back", callback_data: "queue:list" }], [ { text: isPriority ? "๐ŸŸก Priority" : "โšซ๏ธ Priority", callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:priority`, }, { text: isPriority ? "โšซ๏ธ Normal" : "๐Ÿ”ต Normal", callback_data: `queue:prio-set:${chatId}:${replyToMessageId}:normal`, }, ], [ { text: isSkipped ? "โšซ๏ธ Keep" : "๐ŸŸข Keep", callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:keep`, }, { text: isSkipped ? "๐Ÿ”ด Skip" : "โšซ๏ธ Skip", callback_data: `queue:skip-set:${chatId}:${replyToMessageId}:skip`, }, ], ], }; } interface TelegramQueueMenuCallbackDeps { getQueuedItems: () => TelegramQueueMenuItem[]; findItem: ( chatId: number, replyToMessageId: number, ) => TelegramQueueMenuItem | undefined; togglePriority: (chatId: number, replyToMessageId: number) => boolean; setPriority: ( chatId: number, replyToMessageId: number, enabled: boolean, ) => boolean; setSkipped: ( chatId: number, replyToMessageId: number, skipped: boolean, ) => boolean; updateQueueMessage: ( chatId: number, messageId: number, text: string, replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; updateStatus: (ctx: Context) => void; } async function handleTelegramQueueMenuCallback( callbackQueryId: string, data: string, replyChatId: number, replyMessageId: number, ctx: Context, deps: TelegramQueueMenuCallbackDeps, ): Promise { if (!data.startsWith("queue:")) return false; if (data === "queue:noop") { await deps.answerCallbackQuery(callbackQueryId); return true; } if (data === "queue:list") { await updateTelegramQueueMenuList( callbackQueryId, replyChatId, replyMessageId, deps, ); return true; } const refreshMatch = data.match(/^queue:refresh(?::(\d+))?$/); if (refreshMatch) { await updateTelegramQueueMenuList( callbackQueryId, replyChatId, replyMessageId, deps, undefined, refreshMatch[1] === undefined ? 0 : Number(refreshMatch[1]), ); return true; } const pickMatch = data.match(/^queue:pick:(\d+):(\d+)$/); if (pickMatch) { await handleTelegramQueueMenuPick( callbackQueryId, replyChatId, replyMessageId, Number(pickMatch[1]), Number(pickMatch[2]), deps, ); return true; } const prioSetMatch = data.match( /^queue:prio-set:(\d+):(\d+):(priority|normal)$/, ); if (prioSetMatch) { await handleTelegramQueueMenuPrioritySet( callbackQueryId, replyChatId, replyMessageId, Number(prioSetMatch[1]), Number(prioSetMatch[2]), prioSetMatch[3] === "priority", ctx, deps, ); return true; } const prioMatch = data.match(/^queue:prio:(\d+):(\d+)$/); if (prioMatch) { await handleTelegramQueueMenuPriority( callbackQueryId, replyChatId, replyMessageId, Number(prioMatch[1]), Number(prioMatch[2]), ctx, deps, ); return true; } const skipSetMatch = data.match( /^queue:skip-set:(\d+):(\d+):(skip|keep)$/, ); if (skipSetMatch) { await handleTelegramQueueMenuSkipSet( callbackQueryId, replyChatId, replyMessageId, Number(skipSetMatch[1]), Number(skipSetMatch[2]), skipSetMatch[3] === "skip", ctx, deps, ); return true; } return false; } function getTelegramQueueMenuListText( items: readonly TelegramQueueMenuItem[], emptyRefreshIndex?: number, ): string { if (items.length > 0) return "โณ Queue:"; if (emptyRefreshIndex === undefined) return "โŒ› Queue is empty."; return EMPTY_QUEUE_REFRESH_TITLES[ emptyRefreshIndex % EMPTY_QUEUE_REFRESH_TITLES.length ]; } async function updateTelegramQueueMenuList( callbackQueryId: string, replyChatId: number, replyMessageId: number, deps: TelegramQueueMenuCallbackDeps, notice?: string, emptyRefreshIndex?: number, ): Promise { const items = deps.getQueuedItems(); await deps.updateQueueMessage( replyChatId, replyMessageId, getTelegramQueueMenuListText(items, emptyRefreshIndex), buildTelegramQueueMenuReplyMarkup(items, emptyRefreshIndex), ); await deps.answerCallbackQuery(callbackQueryId, notice); } async function refreshStaleTelegramQueueMenuItem( callbackQueryId: string, replyChatId: number, replyMessageId: number, deps: TelegramQueueMenuCallbackDeps, ): Promise { await updateTelegramQueueMenuList( callbackQueryId, replyChatId, replyMessageId, deps, "Item no longer in queue.", ); } async function handleTelegramQueueMenuPick( callbackQueryId: string, replyChatId: number, replyMessageId: number, chatId: number, msgId: number, deps: TelegramQueueMenuCallbackDeps, ): Promise { const item = deps.findItem(chatId, msgId); if (!item) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } await deps.updateQueueMessage( replyChatId, replyMessageId, getTelegramQueueMenuItemText(item), buildTelegramQueueItemSubmenuReplyMarkup( chatId, msgId, item.isPriority, item.reactionSuppressionEmoji !== undefined, ), ); await deps.answerCallbackQuery(callbackQueryId); } async function handleTelegramQueueMenuPriority( callbackQueryId: string, replyChatId: number, replyMessageId: number, chatId: number, msgId: number, ctx: Context, deps: TelegramQueueMenuCallbackDeps, ): Promise { const item = deps.findItem(chatId, msgId); if (!item) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } await updateTelegramQueueMenuPriority( callbackQueryId, replyChatId, replyMessageId, chatId, msgId, !item.isPriority, ctx, deps, ); } async function handleTelegramQueueMenuPrioritySet( callbackQueryId: string, replyChatId: number, replyMessageId: number, chatId: number, msgId: number, enabled: boolean, ctx: Context, deps: TelegramQueueMenuCallbackDeps, ): Promise { const item = deps.findItem(chatId, msgId); if (!item) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } await updateTelegramQueueMenuPriority( callbackQueryId, replyChatId, replyMessageId, chatId, msgId, enabled, ctx, deps, ); } async function updateTelegramQueueMenuPriority( callbackQueryId: string, replyChatId: number, replyMessageId: number, chatId: number, msgId: number, enabled: boolean, ctx: Context, deps: TelegramQueueMenuCallbackDeps, ): Promise { deps.setPriority(chatId, msgId, enabled); deps.updateStatus(ctx); const updated = deps.findItem(chatId, msgId); if (!updated) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } await deps.updateQueueMessage( replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup( chatId, msgId, updated.isPriority, updated.reactionSuppressionEmoji !== undefined, ), ); await deps.answerCallbackQuery( callbackQueryId, updated.isPriority ? "Prioritized." : "Normal priority.", ); } async function handleTelegramQueueMenuSkipSet( callbackQueryId: string, replyChatId: number, replyMessageId: number, chatId: number, msgId: number, skipped: boolean, ctx: Context, deps: TelegramQueueMenuCallbackDeps, ): Promise { const item = deps.findItem(chatId, msgId); if (!item) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } deps.setSkipped(chatId, msgId, skipped); deps.updateStatus(ctx); const updated = deps.findItem(chatId, msgId); if (!updated) { return refreshStaleTelegramQueueMenuItem( callbackQueryId, replyChatId, replyMessageId, deps, ); } await deps.updateQueueMessage( replyChatId, replyMessageId, getTelegramQueueMenuItemText(updated), buildTelegramQueueItemSubmenuReplyMarkup( chatId, msgId, updated.isPriority, updated.reactionSuppressionEmoji !== undefined, ), ); await deps.answerCallbackQuery(callbackQueryId); } interface TelegramQueueMenuCallbackQuery { id: string; data?: string; message?: { chat?: { id?: number }; message_id?: number }; } interface TelegramQueueMenuRuntime { openQueueMenu: ( chatId: number, replyToMessageId: number, ctx: Context, ) => Promise; handleCallbackQuery: ( query: TelegramQueueMenuCallbackQuery, ctx: Context, ) => Promise; } export function createTelegramQueueMenuRuntime< Context, TModel extends MenuModel = MenuModel, >(deps: { telegramQueueStore: Queue.TelegramQueueStateStore; queueMutationRuntime: Queue.TelegramQueueMutationController; sendInteractiveMessage: ( chatId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise; editInteractiveMessage: ( chatId: number, messageId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; getModelMenuState: ( chatId: number, ctx: Context, ) => Promise>; getStoredModelMenuState: ( messageId: number | undefined, chatId?: number, ) => TelegramModelMenuState | undefined; storeModelMenuState: (state: TelegramModelMenuState) => void; updateStatusMessage: ( state: TelegramModelMenuState, ctx: Context, ) => Promise; updateStatus: (ctx: Context) => void; }): TelegramQueueMenuRuntime { const sendQueueMenuMessage = createQueueMenuSendMessageAdapter( deps.sendInteractiveMessage, ); const editQueueMenuMessage = createQueueMenuEditMessageAdapter( deps.editInteractiveMessage, ); return { openQueueMenu: createOpenQueueMenu({ getQueuedItems: deps.telegramQueueStore.getQueuedItems, getModelMenuState: deps.getModelMenuState, storeModelMenuState: deps.storeModelMenuState, sendInteractiveMessage: sendQueueMenuMessage, }), handleCallbackQuery: createQueueMenuCallbackHandler({ telegramQueueStore: deps.telegramQueueStore, queueMutationRuntime: deps.queueMutationRuntime, editInteractiveMessage: editQueueMenuMessage, getStoredModelMenuState: deps.getStoredModelMenuState, updateStatusMessage: deps.updateStatusMessage, answerCallbackQuery: deps.answerCallbackQuery, updateStatus: deps.updateStatus, }), }; } function createOpenQueueMenu< Context, TModel extends MenuModel = MenuModel, >(deps: { getQueuedItems: () => Queue.TelegramQueueItem[]; getModelMenuState: ( chatId: number, ctx: Context, ) => Promise>; storeModelMenuState: (state: TelegramModelMenuState) => void; sendInteractiveMessage: ( chatId: number, replyToMessageId: number, text: string, replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise; }) { return async ( chatId: number, replyToMessageId: number, ctx: Context, ): Promise => { const state = await deps.getModelMenuState(chatId, ctx); const menuItems = toTelegramQueueMenuItems(deps.getQueuedItems()); const text = getTelegramQueueMenuListText(menuItems); const messageId = await deps.sendInteractiveMessage( chatId, replyToMessageId, text, buildTelegramQueueMenuReplyMarkup(menuItems), ); if (messageId === undefined) return; state.messageId = messageId; state.mode = "queue"; deps.storeModelMenuState(state); }; } function createQueueMenuCallbackHandler< Context, TModel extends MenuModel = MenuModel, >(deps: { telegramQueueStore: Queue.TelegramQueueStateStore; queueMutationRuntime: Queue.TelegramQueueMutationController; editInteractiveMessage: ( chatId: number, messageId: number, text: string, replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise; getStoredModelMenuState: ( messageId: number | undefined, chatId?: number, ) => TelegramModelMenuState | undefined; updateStatusMessage: ( state: TelegramModelMenuState, ctx: Context, ) => Promise; answerCallbackQuery: ( callbackQueryId: string, text?: string, ) => Promise; updateStatus: (ctx: Context) => void; }) { return async ( query: TelegramQueueMenuCallbackQuery, ctx: Context, ): Promise => { const data = query.data; const chatId = query.message?.chat?.id; const messageId = query.message?.message_id; if (!data || typeof chatId !== "number" || typeof messageId !== "number") return false; if (data === "menu:queue" || data === "status:queue") { const state = deps.getStoredModelMenuState(messageId, chatId); if (!state) { await deps.answerCallbackQuery( query.id, "Interactive message expired.", ); return true; } const menuItems = toTelegramQueueMenuItems( deps.telegramQueueStore.getQueuedItems(), ); await deps.editInteractiveMessage( chatId, messageId, getTelegramQueueMenuListText(menuItems), buildTelegramQueueMenuReplyMarkup(menuItems), ); state.mode = "queue"; await deps.answerCallbackQuery(query.id); return true; } if (!data.startsWith("queue:")) return false; const getQueueSnapshot = () => { return deps.telegramQueueStore.getQueuedItems(); }; const toMenuItems = () => { return toTelegramQueueMenuItems(getQueueSnapshot()); }; const findItem = (cId: number, rId: number) => { return findTelegramQueueMenuItem(toMenuItems(), cId, rId); }; return handleTelegramQueueMenuCallback( query.id, data, chatId, messageId, ctx, { getQueuedItems: toMenuItems, findItem, togglePriority: (cId, rId) => { return toggleQueuedTelegramPromptPriority(cId, rId, ctx, { getQueueSnapshot, queueMutationRuntime: deps.queueMutationRuntime, }); }, setPriority: (cId, rId, enabled) => { return setQueuedTelegramPromptPriority(cId, rId, enabled, ctx, { getQueueSnapshot, queueMutationRuntime: deps.queueMutationRuntime, }); }, setSkipped: (cId, rId, skipped) => { return setQueuedTelegramPromptSkipped(cId, rId, skipped, ctx, { getQueueSnapshot, queueMutationRuntime: deps.queueMutationRuntime, }); }, updateQueueMessage: deps.editInteractiveMessage, answerCallbackQuery: deps.answerCallbackQuery, updateStatus: deps.updateStatus, }, ); }; } function getQueueMenuReactionDisposition( item: Queue.TelegramQueueItem, priority: boolean, skipped: boolean, ): Queue.TelegramQueueReactionDisposition { if (priority && skipped) { return { kind: "priority-suppressed", priorityEmoji: item.kind === "prompt" ? item.priorityEmoji ?? "โšก" : "โšก", suppressionEmoji: item.kind === "prompt" ? item.reactionSuppressionEmoji ?? "๐Ÿ‘Ž" : "๐Ÿ‘Ž", }; } if (priority) { return { kind: "priority", emoji: item.kind === "prompt" ? item.priorityEmoji ?? "โšก" : "โšก", }; } if (skipped) { return { kind: "suppressed", emoji: item.kind === "prompt" ? item.reactionSuppressionEmoji ?? "๐Ÿ‘Ž" : "๐Ÿ‘Ž", }; } return { kind: "default" }; } function toggleQueuedTelegramPromptPriority( chatId: number, replyToMessageId: number, ctx: Context, deps: { getQueueSnapshot: () => Queue.TelegramQueueItem[]; queueMutationRuntime: Queue.TelegramQueueMutationController; }, ): boolean { const item = findTelegramQueueItem( deps.getQueueSnapshot(), chatId, replyToMessageId, ); if (!item) return false; deps.queueMutationRuntime.applyReactionByMessageId( replyToMessageId, getQueueMenuReactionDisposition( item, item.queueLane !== "priority", item.kind === "prompt" && item.reactionSuppressionEmoji !== undefined, ), ctx, ); return true; } function setQueuedTelegramPromptPriority( chatId: number, replyToMessageId: number, enabled: boolean, ctx: Context, deps: { getQueueSnapshot: () => Queue.TelegramQueueItem[]; queueMutationRuntime: Queue.TelegramQueueMutationController; }, ): boolean { const item = findTelegramQueueItem( deps.getQueueSnapshot(), chatId, replyToMessageId, ); if (!item) return false; deps.queueMutationRuntime.applyReactionByMessageId( replyToMessageId, getQueueMenuReactionDisposition( item, enabled, item.kind === "prompt" && item.reactionSuppressionEmoji !== undefined, ), ctx, ); return true; } function setQueuedTelegramPromptSkipped( chatId: number, replyToMessageId: number, skipped: boolean, ctx: Context, deps: { getQueueSnapshot: () => Queue.TelegramQueueItem[]; queueMutationRuntime: Queue.TelegramQueueMutationController; }, ): boolean { const item = findTelegramQueueItem( deps.getQueueSnapshot(), chatId, replyToMessageId, ); if (!item || item.kind !== "prompt") return false; deps.queueMutationRuntime.applyReactionByMessageId( replyToMessageId, getQueueMenuReactionDisposition( item, item.queueLane === "priority", skipped, ), ctx, ); return true; } function createQueueMenuSendMessageAdapter( sendInteractiveMessage: ( chatId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise, ) { return ( chatId: number, _replyToMessageId: number, text: string, replyMarkup: TelegramQueueMenuReplyMarkup, ): Promise => { return sendInteractiveMessage(chatId, text, "html", replyMarkup); }; } function createQueueMenuEditMessageAdapter( editInteractiveMessage: ( chatId: number, messageId: number, text: string, mode: "html", replyMarkup: TelegramQueueMenuReplyMarkup, ) => Promise, ) { return ( chatId: number, messageId: number, text: string, replyMarkup: TelegramQueueMenuReplyMarkup, ): Promise => { return editInteractiveMessage( chatId, messageId, text, "html", replyMarkup, ).then(() => { return undefined as number | undefined; }); }; }