diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8725030..e57c7b4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Status: unreleased. - TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. - Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. - Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. +- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. - Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index c167ac32a..891ab2b45 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { deleteMessageTelegram, + editMessageTelegram, reactMessageTelegram, sendMessageTelegram, } from "../../telegram/send.js"; @@ -209,5 +210,50 @@ export async function handleTelegramAction( return jsonResult({ ok: true, deleted: true }); } + if (action === "editMessage") { + if (!isActionEnabled("editMessage")) { + throw new Error("Telegram editMessage is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + const content = readStringParam(params, "content", { + required: true, + allowEmpty: false, + }); + const buttons = readTelegramButtons(params); + if (buttons) { + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId: accountId ?? undefined, + }); + if (inlineButtonsScope === "off") { + throw new Error( + 'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".', + ); + } + } + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { + token, + accountId: accountId ?? undefined, + buttons, + }); + return jsonResult({ + ok: true, + messageId: result.messageId, + chatId: result.chatId, + }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts index 6b79bf5ba..b2673134d 100644 --- a/src/channels/plugins/actions/telegram.test.ts +++ b/src/channels/plugins/actions/telegram.test.ts @@ -62,4 +62,53 @@ describe("telegramMessageActions", () => { cfg, ); }); + + it("maps edit action params into editMessage", async () => { + handleTelegramAction.mockClear(); + const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig; + + await telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: 42, + message: "Updated", + buttons: [], + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "editMessage", + chatId: "123", + messageId: 42, + content: "Updated", + buttons: [], + accountId: undefined, + }, + cfg, + ); + }); + + it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { + handleTelegramAction.mockClear(); + const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig; + + await expect( + telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: "nope", + message: "Updated", + }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(); + + expect(handleTelegramAction).not.toHaveBeenCalled(); + }); }); diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index e281772bd..364707e0a 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,5 +1,6 @@ import { createActionGate, + readNumberParam, readStringOrNumberParam, readStringParam, } from "../../../agents/tools/common.js"; @@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const actions = new Set(["send"]); if (gate("reactions")) actions.add("react"); if (gate("deleteMessage")) actions.add("delete"); + if (gate("editMessage")) actions.add("edit"); return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { readStringOrNumberParam(params, "chatId") ?? readStringOrNumberParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); - const messageId = readStringParam(params, "messageId", { + const messageId = readNumberParam(params, "messageId", { required: true, + integer: true, }); return await handleTelegramAction( { action: "deleteMessage", chatId, - messageId: Number(messageId), + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "edit") { + const chatId = + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + const message = readStringParam(params, "message", { required: true, allowEmpty: false }); + const buttons = params.buttons; + return await handleTelegramAction( + { + action: "editMessage", + chatId, + messageId, + content: message, + buttons, accountId: accountId ?? undefined, }, cfg, diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 5d0b80e25..f6a7c3db8 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -15,6 +15,7 @@ export type TelegramActionConfig = { reactions?: boolean; sendMessage?: boolean; deleteMessage?: boolean; + editMessage?: boolean; }; export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index e4943464c..c24b10417 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js"; -import type { DeliverableMessageChannel, GatewayMessageChannel } from "../utils/message-channel.js"; +import type { GatewayMessageChannel } from "../utils/message-channel.js"; export type ResolvedHeartbeatVisibility = { showOk: boolean; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 1006934d3..deebf7c70 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -44,6 +44,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); @@ -88,6 +89,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 2169f197d..6cac2c37c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: { return findings; } -function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { +function collectGatewayConfigFindings( + cfg: ClawdbotConfig, + env: NodeJS.ProcessEnv, +): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies @@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise ({ + botApi: { + editMessageText: vi.fn(), + }, + botCtorSpy: vi.fn(), +})); + +vi.mock("grammy", () => ({ + Bot: class { + api = botApi; + constructor(public token: string) { + botCtorSpy(token); + } + }, + InputFile: class {}, +})); + +import { editMessageTelegram } from "./send.js"; + +describe("editMessageTelegram", () => { + beforeEach(() => { + botApi.editMessageText.mockReset(); + botCtorSpy.mockReset(); + }); + + it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + }); + + expect(botCtorSpy).toHaveBeenCalledWith("tok"); + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const call = botApi.editMessageText.mock.calls[0] ?? []; + const params = call[3] as Record; + expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" })); + expect(params).not.toHaveProperty("reply_markup"); + }); + + it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(params).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + }); + + it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => { + botApi.editMessageText + .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, " html", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(2); + + const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(firstParams).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + + const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record; + expect(secondParams).toEqual( + expect.objectContaining({ + reply_markup: { inline_keyboard: [] }, + }), + ); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index f9557bf1e..43a3a5e8c 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -495,6 +495,99 @@ export async function deleteMessageTelegram( return { ok: true }; } +type TelegramEditOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + textMode?: "markdown" | "html"; + /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ + buttons?: Array>; + /** Optional config injection to avoid global loadConfig() (improves testability). */ + cfg?: ReturnType; +}; + +export async function editMessageTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + text: string, + opts: TelegramEditOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const cfg = opts.cfg ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const chatId = normalizeChatId(String(chatIdInput)); + const messageId = normalizeMessageId(messageIdInput); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + request(fn, label).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + const textMode = opts.textMode ?? "markdown"; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const htmlText = renderTelegramHtmlText(text, { textMode, tableMode }); + + // Reply markup semantics: + // - buttons === undefined → don't send reply_markup (keep existing) + // - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove) + // - otherwise → send built inline keyboard + const shouldTouchButtons = opts.buttons !== undefined; + const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined; + const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined; + + const editParams: Record = { + parse_mode: "HTML", + }; + if (replyMarkup !== undefined) { + editParams.reply_markup = replyMarkup; + } + + await requestWithDiag( + () => api.editMessageText(chatId, messageId, htmlText, editParams), + "editMessage", + ).catch(async (err) => { + // Telegram rejects malformed HTML. Fall back to plain text. + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText)) { + if (opts.verbose) { + console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`); + } + const plainParams: Record = {}; + if (replyMarkup !== undefined) { + plainParams.reply_markup = replyMarkup; + } + return await requestWithDiag( + () => + Object.keys(plainParams).length > 0 + ? api.editMessageText(chatId, messageId, text, plainParams) + : api.editMessageText(chatId, messageId, text), + "editMessage-plain", + ); + } + throw err; + }); + + logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + function inferFilename(kind: ReturnType) { switch (kind) { case "image":