diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe151592..e57c7b4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Status: unreleased. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Docs: add migration guide for moving to a new machine. (#2381) +- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. @@ -43,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. @@ -53,6 +55,7 @@ Status: unreleased. ### Fixes - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. +- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/README.md b/README.md index 535cd1c75..3f8853b93 100644 --- a/README.md +++ b/README.md @@ -477,35 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and Thanks to all clawtributors:

- steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor - vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 - jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest - benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 - stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko - denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst - KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg - Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea - kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg - azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer pookNast Whoaa512 - chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] - damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures - Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal - ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats - 24601 adam91holt ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 - oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd - ClawdFx dguido EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey - jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen - Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz andrewting19 - anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 - Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis - Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps - RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht - snopoke testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai - ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik - hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani - William Stock + steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg + rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan + rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub + abhisekbasu1 jamesgroat claude JustYannicc vignesh07 Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] + lc0rp mousberg mteam88 hirefrank joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo + julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath + gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan + davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 + danielz1z emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams + sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby + buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 + roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer + pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons + austinm911 blacksmith-sh[bot] damoahdominic dan-dr dwfinkelstein HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server + Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) + Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh + svkozak VACInc wes-davis zats 24601 adam91holt ameno- Chris Taylor dguido Django Navarro + evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer + aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe + itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell + odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt + zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro + conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl + gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin kitze + levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch + Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim + Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke Suksham-sharma + testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 + Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev + latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/docs/docs.json b/docs/docs.json index 357d18ad5..9ca48664a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -806,6 +806,10 @@ "source": "/install/railway/", "destination": "/railway" }, + { + "source": "/install/northflank/", + "destination": "/northflank" + }, { "source": "/gcp", "destination": "/platforms/gcp" @@ -853,6 +857,7 @@ "install/docker", "railway", "render", + "northflank", "install/bun" ] }, diff --git a/docs/northflank.mdx b/docs/northflank.mdx new file mode 100644 index 000000000..aae9c6a22 --- /dev/null +++ b/docs/northflank.mdx @@ -0,0 +1,53 @@ +--- +title: Deploy on Northflank +--- + +Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser. +This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you, +and you configure everything via the `/setup` web wizard. + +## How to get started + +1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template. +2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one. +3. Click **Deploy Clawdbot now**. +4. Set the required environment variable: `SETUP_PASSWORD`. +5. Click **Deploy stack** to build and run the Clawdbot template. +6. Wait for the deployment to complete, then click **View resources**. +7. Open the Clawdbot service. +8. Open the public Clawdbot URL and complete setup at `/setup`. +9. Open the Control UI at `/clawdbot`. + +## What you get + +- Hosted Clawdbot Gateway + Control UI +- Web setup wizard at `/setup` (no terminal commands) +- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys + +## Setup flow + +1) Visit `https:///setup` and enter your `SETUP_PASSWORD`. +2) Choose a model/auth provider and paste your key. +3) (Optional) Add Telegram/Discord/Slack tokens. +4) Click **Run setup**. +5) Open the Control UI at `https:///clawdbot` + +If Telegram DMs are set to pairing, the setup wizard can approve the pairing code. + +## Getting chat tokens + +### Telegram bot token + +1) Message `@BotFather` in Telegram +2) Run `/newbot` +3) Copy the token (looks like `123456789:AA...`) +4) Paste it into `/setup` + +### Discord bot token + +1) Go to https://discord.com/developers/applications +2) **New Application** → choose a name +3) **Bot** → **Add Bot** +4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup) +5) Copy the **Bot Token** and paste into `/setup` +6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`) diff --git a/docs/vps.md b/docs/vps.md index 192ab830e..08910733f 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -11,6 +11,8 @@ deployments work at a high level. ## Pick a provider +- **Railway** (one‑click + browser setup): [Railway](/railway) +- **Northflank** (one‑click + browser setup): [Northflank](/northflank) - **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky) - **Fly.io**: [Fly.io](/platforms/fly) - **Hetzner (Docker)**: [Hetzner](/platforms/hetzner) 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/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index bc59b4f2e..45ad44d5a 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]"; export const DEFAULT_GROUP_HISTORY_LIMIT = 50; +/** Maximum number of group history keys to retain (LRU eviction when exceeded). */ +export const MAX_HISTORY_KEYS = 1000; + +/** + * Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS. + * Uses Map's insertion order for LRU-like behavior. + */ +export function evictOldHistoryKeys( + historyMap: Map, + maxKeys: number = MAX_HISTORY_KEYS, +): void { + if (historyMap.size <= maxKeys) return; + const keysToDelete = historyMap.size - maxKeys; + const iterator = historyMap.keys(); + for (let i = 0; i < keysToDelete; i++) { + const key = iterator.next().value; + if (key !== undefined) historyMap.delete(key); + } +} + export type HistoryEntry = { sender: string; body: string; @@ -34,7 +54,13 @@ export function appendHistoryEntry(params: { const history = historyMap.get(historyKey) ?? []; history.push(entry); while (history.length > params.limit) history.shift(); + if (historyMap.has(historyKey)) { + // Refresh insertion order so eviction keeps recently used histories. + historyMap.delete(historyKey); + } historyMap.set(historyKey, history); + // Evict oldest keys if map exceeds max size to prevent unbounded memory growth + evictOldHistoryKeys(historyMap); return history; } diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 970a714d0..0fea27708 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -21,7 +21,11 @@ export async function prependSystemEvents(params: { if (!trimmed) return null; const lower = trimmed.toLowerCase(); if (lower.includes("reason periodic")) return null; - if (lower.includes("heartbeat")) return null; + // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat" + // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this + if (lower.startsWith("read heartbeat.md")) return null; + // Also filter heartbeat poll/wake noise + if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null; if (trimmed.startsWith("Node:")) { return trimmed.replace(/ · last input [^·]+/i, "").trim(); } 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/gateway/server-http.ts b/src/gateway/server-http.ts index 08415f346..b72939c6a 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); - } catch (err) { + } catch { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end(String(err)); + res.end("Internal Server Error"); } } diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index 9d5a4c4ce..eb26bf499 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -37,10 +37,10 @@ function schedule(coalesceMs: number) { pendingReason = reason ?? "retry"; schedule(DEFAULT_RETRY_MS); } - } catch (err) { + } catch { + // Error is already logged by the heartbeat runner; schedule a retry. pendingReason = reason ?? "retry"; schedule(DEFAULT_RETRY_MS); - throw err; } finally { running = false; if (pendingReason || scheduled) schedule(coalesceMs); 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":