Skip to content

Commit 9d7d961

Browse files
steipetefellanH
andcommitted
fix: restore Telegram webhook-mode health after restarts
Landed from contributor PR openclaw#39313 by @fellanH. Co-authored-by: Felix Hellström <[email protected]>
1 parent 1ef8d6a commit 9d7d961

File tree

9 files changed

+63
-4
lines changed

9 files changed

+63
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ Docs: https://docs.openclaw.ai
340340
- 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.
341341
- 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.
342342
- 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.
343+
- 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.
343344

344345
## 2026.3.2
345346

extensions/telegram/src/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
508508
webhookPath: account.config.webhookPath,
509509
webhookHost: account.config.webhookHost,
510510
webhookPort: account.config.webhookPort,
511+
webhookCertPath: account.config.webhookCertPath,
511512
});
512513
},
513514
logoutAccount: async ({ accountId, cfg }) => {

src/config/types.telegram.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@ export type TelegramAccountConfig = {
140140
webhookHost?: string;
141141
/** Local webhook listener bind port (default: 8787). */
142142
webhookPort?: number;
143+
/** Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. */
144+
webhookCertPath?: string;
143145
/** Per-action tool gating (default: true for all). */
144146
actions?: TelegramActionConfig;
145147
/** Telegram thread/conversation binding overrides. */

src/config/zod-schema.providers-core.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ export const TelegramAccountSchemaBase = z
221221
.describe(
222222
"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.",
223223
),
224+
webhookCertPath: z
225+
.string()
226+
.optional()
227+
.describe(
228+
"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).",
229+
),
224230
actions: z
225231
.object({
226232
reactions: z.boolean().optional(),

src/gateway/channel-health-policy.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,27 @@ describe("evaluateChannelHealth", () => {
143143
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
144144
});
145145

146+
it("skips stale-socket detection for channels in webhook mode", () => {
147+
const evaluation = evaluateChannelHealth(
148+
{
149+
running: true,
150+
connected: true,
151+
enabled: true,
152+
configured: true,
153+
lastStartAt: 0,
154+
lastEventAt: 0,
155+
mode: "webhook",
156+
},
157+
{
158+
channelId: "discord",
159+
now: 100_000,
160+
channelConnectGraceMs: 10_000,
161+
staleEventThresholdMs: 30_000,
162+
},
163+
);
164+
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
165+
});
166+
146167
it("does not flag stale sockets for channels without event tracking", () => {
147168
const evaluation = evaluateChannelHealth(
148169
{

src/gateway/channel-health-policy.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type ChannelHealthSnapshot = {
1212
lastEventAt?: number | null;
1313
lastStartAt?: number | null;
1414
reconnectAttempts?: number;
15+
mode?: string;
1516
};
1617

1718
export type ChannelHealthEvaluationReason =
@@ -105,11 +106,13 @@ export function evaluateChannelHealth(
105106
if (snapshot.connected === false) {
106107
return { healthy: false, reason: "disconnected" };
107108
}
108-
// Skip stale-socket check for Telegram (long-polling mode). Each polling request
109-
// acts as a heartbeat, so the half-dead WebSocket scenario this check is designed
110-
// to catch does not apply to Telegram's long-polling architecture.
109+
// Skip stale-socket check for Telegram (long-polling mode) and any channel
110+
// explicitly operating in webhook mode. In these cases, there is no persistent
111+
// outgoing socket that can go half-dead, so the lack of incoming events
112+
// does not necessarily indicate a connection failure.
111113
if (
112114
policy.channelId !== "telegram" &&
115+
snapshot.mode !== "webhook" &&
113116
snapshot.connected === true &&
114117
snapshot.lastEventAt != null
115118
) {

src/telegram/monitor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type MonitorTelegramOpts = {
3030
webhookHost?: string;
3131
proxyFetch?: typeof fetch;
3232
webhookUrl?: string;
33+
webhookCertPath?: string;
3334
};
3435

3536
export function createTelegramRunnerOptions(cfg: OpenClawConfig): RunOptions<unknown> {
@@ -199,6 +200,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
199200
fetch: proxyFetch,
200201
abortSignal: opts.abortSignal,
201202
publicUrl: opts.webhookUrl,
203+
webhookCertPath: opts.webhookCertPath,
202204
});
203205
await waitForAbortSignal(opts.abortSignal);
204206
return;

src/telegram/webhook.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,27 @@ describe("startTelegramWebhook", () => {
353353
);
354354
});
355355

356+
it("registers webhook with certificate when webhookCertPath is provided", async () => {
357+
setWebhookSpy.mockClear();
358+
await withStartedWebhook(
359+
{
360+
secret: TELEGRAM_SECRET,
361+
path: TELEGRAM_WEBHOOK_PATH,
362+
webhookCertPath: "/path/to/cert.pem",
363+
},
364+
async () => {
365+
expect(setWebhookSpy).toHaveBeenCalledWith(
366+
expect.any(String),
367+
expect.objectContaining({
368+
certificate: expect.objectContaining({
369+
fileData: "/path/to/cert.pem",
370+
}),
371+
}),
372+
);
373+
},
374+
);
375+
});
376+
356377
it("invokes webhook handler on matching path", async () => {
357378
handlerSpy.mockClear();
358379
createTelegramBotSpy.mockClear();

src/telegram/webhook.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createServer } from "node:http";
2-
import { webhookCallback } from "grammy";
2+
import { InputFile, webhookCallback } from "grammy";
33
import type { OpenClawConfig } from "../config/config.js";
44
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
55
import { formatErrorMessage } from "../infra/errors.js";
@@ -87,6 +87,7 @@ export async function startTelegramWebhook(opts: {
8787
abortSignal?: AbortSignal;
8888
healthPath?: string;
8989
publicUrl?: string;
90+
webhookCertPath?: string;
9091
}) {
9192
const path = opts.path ?? "/telegram-webhook";
9293
const healthPath = opts.healthPath ?? "/healthz";
@@ -241,6 +242,7 @@ export async function startTelegramWebhook(opts: {
241242
bot.api.setWebhook(publicUrl, {
242243
secret_token: secret,
243244
allowed_updates: resolveTelegramAllowedUpdates(),
245+
certificate: opts.webhookCertPath ? new InputFile(opts.webhookCertPath) : undefined,
244246
}),
245247
});
246248
} catch (err) {

0 commit comments

Comments
 (0)