feat(telegram): add edit message action (#2394) (thanks @marcelomar21)

This commit is contained in:
vignesh07
2026-01-26 15:26:15 -08:00
committed by Vignesh
parent 5c35b62a5c
commit 343882d45c
10 changed files with 319 additions and 6 deletions

View File

@@ -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.

View File

@@ -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}`);
}

View File

@@ -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();
});
});

View File

@@ -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<ChannelMessageActionName>(["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,

View File

@@ -15,6 +15,7 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
editMessage?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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<Secu
findings.push(...collectAttackSurfaceSummaryFindings(cfg));
findings.push(...collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg));
findings.push(...collectGatewayConfigFindings(cfg, env));
findings.push(...collectBrowserControlFindings(cfg));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));

View File

@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { botApi, botCtorSpy } = vi.hoisted(() => ({
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<string, unknown>;
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<string, unknown>;
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, "<bad> html", {
token: "tok",
cfg: {},
buttons: [],
});
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record<string, unknown>;
expect(firstParams).toEqual(
expect.objectContaining({
parse_mode: "HTML",
reply_markup: { inline_keyboard: [] },
}),
);
const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record<string, unknown>;
expect(secondParams).toEqual(
expect.objectContaining({
reply_markup: { inline_keyboard: [] },
}),
);
});
});

View File

@@ -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<Array<{ text: string; callback_data: string }>>;
/** Optional config injection to avoid global loadConfig() (improves testability). */
cfg?: ReturnType<typeof loadConfig>;
};
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 = <T>(fn: () => Promise<T>, 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<string, unknown> = {
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<string, unknown> = {};
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<typeof mediaKindFromMime>) {
switch (kind) {
case "image":