diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 34595f7c2d6..92f292a8b14 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -238,6 +238,110 @@ export const registerTelegramHandlers = ({ : async () => ({}); return { message, me: ctx.me, getFile }; }; + + const MULTI_SELECT_PREFIX = "OC_MULTI|"; + const SELECT_PREFIX = "OC_SELECT|"; + const SELECTED_PREFIX = "✅ "; + + type TelegramCallbackButton = { + text: string; + callback_data: string; + style?: "danger" | "success" | "primary"; + }; + + const cloneInlineKeyboardButtons = (message: Message): TelegramCallbackButton[][] => { + const rows = (message as { reply_markup?: { inline_keyboard?: unknown } }).reply_markup + ?.inline_keyboard; + if (!Array.isArray(rows)) { + return []; + } + return rows + .map((row) => + Array.isArray(row) + ? row + .map((button): TelegramCallbackButton | null => { + const candidate = button as { + text?: unknown; + callback_data?: unknown; + style?: unknown; + }; + if ( + typeof candidate.text !== "string" || + typeof candidate.callback_data !== "string" + ) { + return null; + } + const style = + candidate.style === "danger" || + candidate.style === "success" || + candidate.style === "primary" + ? candidate.style + : undefined; + return { + text: candidate.text, + callback_data: candidate.callback_data, + ...(style ? { style } : {}), + }; + }) + .filter((button): button is TelegramCallbackButton => button !== null) + : [], + ) + .filter((row) => row.length > 0); + }; + const stripMultiSelectPrefix = (text: string): string => text.replace(/^✅\s*/, ""); + const isSelectedMultiButton = (button: TelegramCallbackButton): boolean => + /^✅\s*/.test(button.text); + const isMultiToggleButton = (button: TelegramCallbackButton): boolean => + typeof button.callback_data === "string" && + button.callback_data.startsWith(`${MULTI_SELECT_PREFIX}toggle|`); + const resolveMultiSelectedValues = (buttons: TelegramCallbackButton[][]): string[] => + buttons.flatMap((row) => + row.flatMap((button) => { + if (!isMultiToggleButton(button) || !isSelectedMultiButton(button)) { + return []; + } + return [button.callback_data!.slice(`${MULTI_SELECT_PREFIX}toggle|`.length)]; + }), + ); + const updateMultiSelectKeyboard = ( + message: Message, + action: "toggle" | "clear", + value = "", + ): TelegramCallbackButton[][] => + cloneInlineKeyboardButtons(message).map((row) => + row.map((button) => { + if (!isMultiToggleButton(button)) { + return button; + } + const buttonValue = button.callback_data!.slice(`${MULTI_SELECT_PREFIX}toggle|`.length); + const baseText = stripMultiSelectPrefix(button.text); + const selected = + action === "clear" + ? false + : buttonValue === value + ? !isSelectedMultiButton(button) + : isSelectedMultiButton(button); + return { + ...button, + text: selected ? `${SELECTED_PREFIX}${baseText}` : baseText, + }; + }), + ); + const buildCallbackSyntheticTextContext = (params: { + ctx: Pick & { getFile?: unknown }; + callbackMessage: Message; + callback: { from?: Message["from"] }; + text: string; + isForum: boolean; + }): { ctx: TelegramContext; message: Message } => { + const message = buildSyntheticTextMessage({ + base: withResolvedTelegramForumFlag(params.callbackMessage, params.isForum), + from: params.callback.from, + text: params.text, + }); + return { ctx: buildSyntheticContext(params.ctx, message), message }; + }; + const inboundDebouncer = createInboundDebouncer({ debounceMs, resolveDebounceMs: (entry) => @@ -1590,6 +1694,65 @@ export const registerTelegramHandlers = ({ return; } + if (data.startsWith(MULTI_SELECT_PREFIX)) { + const [, action, value = ""] = data.split("|"); + if (action === "toggle" || action === "clear") { + const buttons = updateMultiSelectKeyboard(callbackMessage, action, value); + if (buttons.length > 0) { + try { + await editCallbackButtons(buttons); + } catch (editErr) { + if (!String(editErr).includes("message is not modified")) { + throw new TelegramRetryableCallbackError(editErr); + } + } + } + return; + } + if (action === "submit") { + const selected = resolveMultiSelectedValues(cloneInlineKeyboardButtons(callbackMessage)); + const synthetic = buildCallbackSyntheticTextContext({ + ctx, + callbackMessage, + callback, + text: `Multi-select submitted: ${selected.length > 0 ? selected.join(", ") : "none"}`, + isForum, + }); + await processMessageWithReplyChain(synthetic.ctx, synthetic.message, [], storeAllowFrom, { + forceWasMentioned: true, + messageIdOverride: callback.id, + }); + return; + } + } + + if (data.startsWith(SELECT_PREFIX)) { + const value = data.slice(SELECT_PREFIX.length); + try { + await clearCallbackButtons(); + } catch (editErr) { + const errStr = String(editErr); + if ( + !errStr.includes("message is not modified") && + !errStr.includes("there is no text in the message to edit") + ) { + throw new TelegramRetryableCallbackError(editErr); + } + } + const synthetic = buildCallbackSyntheticTextContext({ + ctx, + callbackMessage, + callback, + text: `Single-select submitted: ${value}`, + isForum, + }); + await processMessageWithReplyChain(synthetic.ctx, synthetic.message, [], storeAllowFrom, { + forceWasMentioned: true, + messageIdOverride: callback.id, + }); + return; + } + if (approvalCallback) { const isPluginApproval = approvalCallback.approvalId.startsWith("plugin:"); const pluginApprovalAuthorizedSender = isTelegramExecApprovalApprover({ diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index c06fe37b537..1ad1a30413b 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -751,6 +751,111 @@ describe("createTelegramBot", () => { expect(payload.Body).toContain("cmd:option_a"); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1"); }); + + it("toggles OC_MULTI buttons without routing through the generic callback message path", async () => { + createTelegramBot({ token: "tok" }); + const callbackHandler = requireValue( + onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as + | ((ctx: Record) => Promise) + | undefined, + "callback_query handler", + ); + + await callbackHandler({ + callbackQuery: { + id: "cbq-multi-toggle-1", + data: "OC_MULTI|toggle|red", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + reply_markup: { + inline_keyboard: [[{ text: "Red", callback_data: "OC_MULTI|toggle|red" }]], + }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, { + reply_markup: { + inline_keyboard: [[{ text: "✅ Red", callback_data: "OC_MULTI|toggle|red" }]], + }, + }); + expect(replySpy).not.toHaveBeenCalled(); + expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-multi-toggle-1"); + }); + + it("submits OC_MULTI selections as a synthetic inbound message", async () => { + createTelegramBot({ token: "tok" }); + const callbackHandler = requireValue( + onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as + | ((ctx: Record) => Promise) + | undefined, + "callback_query handler", + ); + + await callbackHandler({ + callbackQuery: { + id: "cbq-multi-submit-1", + data: "OC_MULTI|submit", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + reply_markup: { + inline_keyboard: [ + [{ text: "✅ Red", callback_data: "OC_MULTI|toggle|red" }], + [{ text: "Blue", callback_data: "OC_MULTI|toggle|blue" }], + ], + }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].Body).toContain("Multi-select submitted: red"); + }); + + it("submits OC_SELECT values as a synthetic inbound message and clears buttons", async () => { + createTelegramBot({ token: "tok" }); + const callbackHandler = requireValue( + onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as + | ((ctx: Record) => Promise) + | undefined, + "callback_query handler", + ); + + await callbackHandler({ + callbackQuery: { + id: "cbq-select-1", + data: "OC_SELECT|alpha", + from: { id: 9, first_name: "Ada", username: "ada_bot" }, + message: { + chat: { id: 1234, type: "private" }, + date: 1736380800, + message_id: 10, + reply_markup: { + inline_keyboard: [[{ text: "Alpha", callback_data: "OC_SELECT|alpha" }]], + }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, { + reply_markup: { inline_keyboard: [] }, + }); + expect(replySpy).toHaveBeenCalledTimes(1); + expect(replySpy.mock.calls[0][0].Body).toContain("Single-select submitted: alpha"); + }); + it("preserves native command source for prefixed callback_query payloads", async () => { loadConfig.mockReturnValue({ commands: { text: false, native: true }, diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 9f1968545ca..06c22c95295 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -1211,6 +1211,62 @@ describe("deliverSubagentAnnouncement completion delivery", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + it("reports subagent group completions that miss required message-tool delivery", async () => { + const callGateway = createGatewayMock({ + result: { + payloads: [ + { + text: "Child result that must not be raw-sent.", + }, + ], + }, + }); + const sendMessage = createSendMessageMock(); + const result = await deliverSlackChannelAnnouncement({ + callGateway, + sendMessage, + sessionId: "requester-session-channel", + isActive: false, + expectsCompletionMessage: true, + directIdempotencyKey: "announce-channel-subagent-message-tool", + sourceTool: "subagent_announce", + internalEvents: [ + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:openclaw:subagent:child-123", + childSessionId: "child-123", + announceType: "subagent task", + status: "ok", + statusLabel: "completed successfully", + result: "Raw child result that should stay internal.", + replyInstruction: "Let the requester/orchestrator deliver the final response.", + }, + ], + }); + + expect(result).toEqual( + expect.objectContaining({ + delivered: false, + path: "direct", + error: "completion agent did not deliver through the message tool", + }), + ); + expect(callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "agent", + params: expect.objectContaining({ + deliver: false, + channel: "slack", + accountId: "acct-1", + to: "channel:C123", + threadId: undefined, + }), + }), + ); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it("does not fallback for generated media group completions when message tool evidence exists", async () => { const callGateway = createGatewayMock({ result: { diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index f253566a5c2..529922cdf75 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -56,7 +56,11 @@ import type { SpawnSubagentMode } from "./subagent-spawn.types.js"; const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000; const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000; -const AGENT_MEDIATED_COMPLETION_TOOLS = new Set(["music_generate", "video_generate"]); +const AGENT_MEDIATED_COMPLETION_TOOLS = new Set([ + "music_generate", + "video_generate", + "subagent_announce", +]); type SubagentAnnounceDeliveryDeps = { callGateway: typeof callGateway;