fix: restore Telegram webhook-mode health after restarts

Landed from contributor PR #39313 by @fellanH.

Co-authored-by: Felix Hellström <30758862+fellanH@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-03-08 02:27:05 +00:00
parent 1ef8d6a01b
commit 9d7d961db8
9 changed files with 63 additions and 4 deletions

View File

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

View File

@ -508,6 +508,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
webhookPath: account.config.webhookPath,
webhookHost: account.config.webhookHost,
webhookPort: account.config.webhookPort,
webhookCertPath: account.config.webhookCertPath,
});
},
logoutAccount: async ({ accountId, cfg }) => {

View File

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

View File

@ -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(),

View File

@ -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(
{

View File

@ -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
) {

View File

@ -30,6 +30,7 @@ export type MonitorTelegramOpts = {
webhookHost?: string;
proxyFetch?: typeof fetch;
webhookUrl?: string;
webhookCertPath?: string;
};
export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions<unknown> {
@ -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;

View File

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

View File

@ -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) {