From 9d7d961db82a7381288372ceb33fd0cb083586c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 02:27:05 +0000 Subject: [PATCH] fix: restore Telegram webhook-mode health after restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landed from contributor PR #39313 by @fellanH. Co-authored-by: Felix Hellström <30758862+fellanH@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/telegram/src/channel.ts | 1 + src/config/types.telegram.ts | 2 ++ src/config/zod-schema.providers-core.ts | 6 ++++++ src/gateway/channel-health-policy.test.ts | 21 +++++++++++++++++++++ src/gateway/channel-health-policy.ts | 9 ++++++--- src/telegram/monitor.ts | 2 ++ src/telegram/webhook.test.ts | 21 +++++++++++++++++++++ src/telegram/webhook.ts | 4 +++- 9 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb99a69f2d..da25d0bb436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -340,6 +340,7 @@ Docs: https://docs.openclaw.ai - Agents/codex-cli sandbox defaults: switch the built-in Codex backend from `read-only` to `workspace-write` so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping. - Gateway/health-monitor restart reason labeling: report `disconnected` instead of `stuck` for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin. - Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax. +- Gateway/Telegram webhook-mode recovery: add `webhookCertPath` to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH. ## 2026.3.2 diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 12747ad755f..2cd2bf8ff51 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -508,6 +508,7 @@ export const telegramPlugin: ChannelPlugin { diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 28adb785db1..ce8ad105b06 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -140,6 +140,8 @@ export type TelegramAccountConfig = { webhookHost?: string; /** Local webhook listener bind port (default: 8787). */ webhookPort?: number; + /** Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. */ + webhookCertPath?: string; /** Per-action tool gating (default: true for all). */ actions?: TelegramActionConfig; /** Telegram thread/conversation binding overrides. */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d01ad612153..6bd70283f7c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -221,6 +221,12 @@ export const TelegramAccountSchemaBase = z .describe( "Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.", ), + webhookCertPath: z + .string() + .optional() + .describe( + "Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).", + ), actions: z .object({ reactions: z.boolean().optional(), diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index 906ea559bde..0a2c34604fa 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -143,6 +143,27 @@ describe("evaluateChannelHealth", () => { expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); }); + it("skips stale-socket detection for channels in webhook mode", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 0, + lastEventAt: 0, + mode: "webhook", + }, + { + channelId: "discord", + now: 100_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); + }); + it("does not flag stale sockets for channels without event tracking", () => { const evaluation = evaluateChannelHealth( { diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index 57362054b80..7fed6fe7dad 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -12,6 +12,7 @@ export type ChannelHealthSnapshot = { lastEventAt?: number | null; lastStartAt?: number | null; reconnectAttempts?: number; + mode?: string; }; export type ChannelHealthEvaluationReason = @@ -105,11 +106,13 @@ export function evaluateChannelHealth( if (snapshot.connected === false) { return { healthy: false, reason: "disconnected" }; } - // Skip stale-socket check for Telegram (long-polling mode). Each polling request - // acts as a heartbeat, so the half-dead WebSocket scenario this check is designed - // to catch does not apply to Telegram's long-polling architecture. + // Skip stale-socket check for Telegram (long-polling mode) and any channel + // explicitly operating in webhook mode. In these cases, there is no persistent + // outgoing socket that can go half-dead, so the lack of incoming events + // does not necessarily indicate a connection failure. if ( policy.channelId !== "telegram" && + snapshot.mode !== "webhook" && snapshot.connected === true && snapshot.lastEventAt != null ) { diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index c4e12959953..6325670f298 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -30,6 +30,7 @@ export type MonitorTelegramOpts = { webhookHost?: string; proxyFetch?: typeof fetch; webhookUrl?: string; + webhookCertPath?: string; }; export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions { @@ -199,6 +200,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { fetch: proxyFetch, abortSignal: opts.abortSignal, publicUrl: opts.webhookUrl, + webhookCertPath: opts.webhookCertPath, }); await waitForAbortSignal(opts.abortSignal); return; diff --git a/src/telegram/webhook.test.ts b/src/telegram/webhook.test.ts index b2863a11dbb..1b630b034df 100644 --- a/src/telegram/webhook.test.ts +++ b/src/telegram/webhook.test.ts @@ -353,6 +353,27 @@ describe("startTelegramWebhook", () => { ); }); + it("registers webhook with certificate when webhookCertPath is provided", async () => { + setWebhookSpy.mockClear(); + await withStartedWebhook( + { + secret: TELEGRAM_SECRET, + path: TELEGRAM_WEBHOOK_PATH, + webhookCertPath: "/path/to/cert.pem", + }, + async () => { + expect(setWebhookSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + certificate: expect.objectContaining({ + fileData: "/path/to/cert.pem", + }), + }), + ); + }, + ); + }); + it("invokes webhook handler on matching path", async () => { handlerSpy.mockClear(); createTelegramBotSpy.mockClear(); diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts index 8333a6a1ebe..1de38b1bb36 100644 --- a/src/telegram/webhook.ts +++ b/src/telegram/webhook.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { webhookCallback } from "grammy"; +import { InputFile, webhookCallback } from "grammy"; import type { OpenClawConfig } from "../config/config.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -87,6 +87,7 @@ export async function startTelegramWebhook(opts: { abortSignal?: AbortSignal; healthPath?: string; publicUrl?: string; + webhookCertPath?: string; }) { const path = opts.path ?? "/telegram-webhook"; const healthPath = opts.healthPath ?? "/healthz"; @@ -241,6 +242,7 @@ export async function startTelegramWebhook(opts: { bot.api.setWebhook(publicUrl, { secret_token: secret, allowed_updates: resolveTelegramAllowedUpdates(), + certificate: opts.webhookCertPath ? new InputFile(opts.webhookCertPath) : undefined, }), }); } catch (err) {