fix(telegram): report webhook registration status

This commit is contained in:
Peter Steinberger
2026-04-29 15:40:36 +01:00
parent 7108414009
commit 204ef7f1c4
11 changed files with 268 additions and 13 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Channels/Telegram: continue polling when `deleteWebhook` hits a transient network failure but `getWebhookInfo` confirms no webhook is configured, so startup does not retry cleanup forever after the webhook was already removed. Refs #74086; carries forward #47384. Thanks @clovericbot.
- Channels/Telegram: apply strict safe-send retry to inbound final replies when grammY wraps a pre-connect failure, while leaving ambiguous plain network envelopes single-shot to avoid duplicate visible messages. Fixes #74203. Thanks @nanli2000cn.
- Channels/Telegram: surface polling liveness warnings in channel status and doctor when a running long-poller has not completed `getUpdates` after startup grace or its transport activity is stale, so silent polling failures no longer look clean. Refs #74299. Thanks @lolaopenclaw.
- Channels/Telegram: publish webhook runtime state and warn when `setWebhook` has not completed after startup grace, so webhook-mode accounts no longer look healthy while registration is still failing or retrying. Refs #74299. Thanks @lolaopenclaw and @martingarramon.
- Channels/Telegram: bound native command menu `deleteMyCommands` and `setMyCommands` Bot API calls and allow the same timeout-triggered transport fallback retry as other startup control calls, so Windows/WSL network stalls cannot leave command sync hanging behind an otherwise running provider. Refs #74086. Thanks @SymbolStar.
- ACP/commands: accept forwarded ACP timeout config controls in the OpenClaw bridge, treat unsupported discard-close controls as recoverable cleanup, and restore native `/verbose full` plus no-arg status behavior, so Discord command menus and nested ACP turns no longer fail on supported session controls. Thanks @vincentkoc.
- TUI/status: clear stale `streaming` footer state when a final event arrives after the active run was already cleared and no tracked runs remain, while preserving concurrent-run ownership and inactive local `/btw` terminal handling. Fixes #64825; carries forward #64842, #64843, #64847, and #64862. Thanks @briandevans and @Yanhu007.

View File

@@ -867,7 +867,7 @@ Per-account, per-group, and per-topic overrides are supported (same inheritance
- Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures.
- If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors.
- If logs include `Polling stall detected`, OpenClaw restarts polling and rebuilds the Telegram transport after 120 seconds without completed long-poll liveness by default.
- `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, or when its last successful polling transport activity is stale.
- `openclaw channels status --probe` and `openclaw doctor` warn when a running polling account has not completed `getUpdates` after startup grace, when a running webhook account has not completed `setWebhook` after startup grace, or when the last successful polling transport activity is stale.
- Increase `channels.telegram.pollingStallThresholdMs` only when long-running `getUpdates` calls are healthy but your host still reports false polling-stall restarts. Persistent stalls usually point to proxy, DNS, IPv6, or TLS egress issues between the host and `api.telegram.org`.
- Telegram also honors process proxy env for Bot API transport, including `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and their lowercase variants. `NO_PROXY` / `no_proxy` can still bypass `api.telegram.org`.
- If the OpenClaw managed proxy is configured through `OPENCLAW_PROXY_URL` for a service environment and no standard proxy env is present, Telegram uses that URL for Bot API transport too.

View File

@@ -774,11 +774,13 @@ describe("monitorTelegramProvider (grammY)", () => {
});
it("passes configured webhookHost to webhook listener", async () => {
const setStatus = vi.fn();
await monitorTelegramProvider({
token: "tok",
useWebhook: true,
webhookUrl: "https://example.test/telegram",
webhookSecret: "secret",
setStatus,
config: {
agents: { defaults: { maxConcurrent: 2 } },
channels: {
@@ -792,6 +794,7 @@ describe("monitorTelegramProvider (grammY)", () => {
expect(startTelegramWebhookSpy).toHaveBeenCalledWith(
expect.objectContaining({
host: "0.0.0.0",
setStatus,
}),
);
expect(runSpy).not.toHaveBeenCalled();

View File

@@ -154,6 +154,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
abortSignal: opts.abortSignal,
publicUrl: opts.webhookUrl,
webhookCertPath: opts.webhookCertPath,
setStatus: opts.setStatus,
});
await waitForAbortSignal(opts.abortSignal);
return;

View File

@@ -12,6 +12,7 @@ import {
const TELEGRAM_POLLING_CONNECT_GRACE_MS = 120_000;
const TELEGRAM_POLLING_STALE_TRANSPORT_MS = 30 * 60_000;
const TELEGRAM_WEBHOOK_CONNECT_GRACE_MS = 120_000;
type TelegramAccountStatus = {
accountId?: unknown;
@@ -124,6 +125,40 @@ function collectTelegramPollingRuntimeIssues(params: {
}
}
function collectTelegramWebhookRuntimeIssues(params: {
account: TelegramAccountStatus;
accountId: string;
issues: ChannelStatusIssue[];
now: number;
}) {
const { account, accountId, issues, now } = params;
if (account.running !== true || asString(account.mode) !== "webhook") {
return;
}
if (account.connected !== false) {
return;
}
const lastStartAt = asFiniteNumber(account.lastStartAt);
const withinStartupGrace =
lastStartAt != null && now - lastStartAt < TELEGRAM_WEBHOOK_CONNECT_GRACE_MS;
if (withinStartupGrace) {
return;
}
issues.push({
channel: "telegram",
accountId,
kind: "runtime",
message: appendTelegramRuntimeError(
"Telegram webhook listener is running but setWebhook has not completed since startup",
account.lastError,
),
fix: `Run: ${formatCliCommand("openclaw channels status --probe")} (or restart the gateway). Check the webhook URL, secret, TLS/proxy reachability, and Telegram setWebhook logs if it persists.`,
});
}
function readTelegramGroupMembershipAuditSummary(
value: unknown,
): TelegramGroupMembershipAuditSummary {
@@ -174,12 +209,19 @@ export function collectTelegramStatusIssues(
if (!accountId) {
continue;
}
const now = Date.now();
collectTelegramPollingRuntimeIssues({
account,
accountId,
issues,
now: Date.now(),
now,
});
collectTelegramWebhookRuntimeIssues({
account,
accountId,
issues,
now,
});
if (account.allowUnmentionedGroups === true) {

View File

@@ -156,7 +156,7 @@ describe("collectTelegramStatusIssues", () => {
expect(issues).toEqual([]);
});
it("does not report webhook accounts with polling-only runtime diagnostics", () => {
it("reports webhook runtime state that never completed setWebhook after startup grace", () => {
const issues = collectTelegramStatusIssues([
{
accountId: "main",
@@ -166,6 +166,48 @@ describe("collectTelegramStatusIssues", () => {
mode: "webhook",
connected: false,
lastStartAt: Date.now() - 10 * 60_000,
lastError: "fetch failed",
} as ChannelAccountSnapshot,
]);
expect(issues).toEqual([
expect.objectContaining({
channel: "telegram",
accountId: "main",
kind: "runtime",
message: expect.stringContaining("setWebhook has not completed"),
}),
]);
expect(issues[0]?.message).toContain("fetch failed");
expect(issues[0]?.fix).toContain("webhook URL");
});
it("does not report webhook startup before the connect grace expires", () => {
const issues = collectTelegramStatusIssues([
{
accountId: "main",
enabled: true,
configured: true,
running: true,
mode: "webhook",
connected: false,
lastStartAt: Date.now() - 60_000,
} as ChannelAccountSnapshot,
]);
expect(issues).toEqual([]);
});
it("does not report an advertised webhook just because no user updates arrived", () => {
const issues = collectTelegramStatusIssues([
{
accountId: "main",
enabled: true,
configured: true,
running: true,
mode: "webhook",
connected: true,
lastStartAt: Date.now() - 60 * 60_000,
} as ChannelAccountSnapshot,
]);

View File

@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import { createTelegramWebhookStatusPublisher } from "./webhook-status.js";
describe("createTelegramWebhookStatusPublisher", () => {
it("publishes start, advertised, update, failure, and stop status patches", () => {
const setStatus = vi.fn();
const status = createTelegramWebhookStatusPublisher(setStatus);
status.noteWebhookStart();
status.noteWebhookAdvertised(1234);
status.noteWebhookUpdateReceived(2345);
status.noteWebhookRegistrationFailure("fetch failed");
status.noteWebhookStop();
expect(setStatus).toHaveBeenNthCalledWith(1, {
mode: "webhook",
connected: false,
lastConnectedAt: null,
lastEventAt: null,
lastTransportActivityAt: null,
});
expect(setStatus).toHaveBeenNthCalledWith(2, {
mode: "webhook",
connected: true,
lastConnectedAt: 1234,
lastEventAt: 1234,
lastError: null,
});
expect(setStatus).toHaveBeenNthCalledWith(3, {
mode: "webhook",
connected: true,
lastConnectedAt: 2345,
lastEventAt: 2345,
lastError: null,
});
expect(setStatus).toHaveBeenNthCalledWith(4, {
mode: "webhook",
connected: false,
lastError: "fetch failed",
});
expect(setStatus).toHaveBeenNthCalledWith(5, {
mode: "webhook",
connected: false,
});
});
});

View File

@@ -0,0 +1,45 @@
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
type TelegramWebhookStatusSink = (patch: Omit<ChannelAccountSnapshot, "accountId">) => void;
export function createTelegramWebhookStatusPublisher(setStatus?: TelegramWebhookStatusSink) {
return {
noteWebhookStart() {
setStatus?.({
mode: "webhook",
connected: false,
lastConnectedAt: null,
lastEventAt: null,
lastTransportActivityAt: null,
});
},
noteWebhookAdvertised(at = Date.now()) {
setStatus?.({
...createConnectedChannelStatusPatch(at),
mode: "webhook",
lastError: null,
});
},
noteWebhookUpdateReceived(at = Date.now()) {
setStatus?.({
...createConnectedChannelStatusPatch(at),
mode: "webhook",
lastError: null,
});
},
noteWebhookRegistrationFailure(error: string) {
setStatus?.({
mode: "webhook",
connected: false,
lastError: error,
});
},
noteWebhookStop() {
setStatus?.({
mode: "webhook",
connected: false,
});
},
};
}

View File

@@ -412,6 +412,7 @@ describe("startTelegramWebhook", () => {
initSpy.mockClear();
createTelegramBotSpy.mockClear();
const runtimeLog = vi.fn();
const setStatus = vi.fn();
const cfg = { bindings: [] };
await withStartedWebhook(
{
@@ -419,6 +420,7 @@ describe("startTelegramWebhook", () => {
accountId: "opie",
config: cfg,
runtime: { log: runtimeLog, error: vi.fn(), exit: vi.fn() },
setStatus,
},
async ({ port }) => {
expect(createTelegramBotSpy).toHaveBeenCalledWith(
@@ -438,6 +440,20 @@ describe("startTelegramWebhook", () => {
expect(runtimeLog).toHaveBeenCalledWith(
expect.stringContaining("webhook advertised to telegram on http://"),
);
expect(setStatus).toHaveBeenNthCalledWith(1, {
mode: "webhook",
connected: false,
lastConnectedAt: null,
lastEventAt: null,
lastTransportActivityAt: null,
});
expect(setStatus).toHaveBeenNthCalledWith(2, {
mode: "webhook",
connected: true,
lastConnectedAt: expect.any(Number),
lastEventAt: expect.any(Number),
lastError: null,
});
},
);
});
@@ -445,6 +461,7 @@ describe("startTelegramWebhook", () => {
it("keeps local listener alive and retries when setWebhook has a recoverable startup failure", async () => {
const runtimeLog = vi.fn();
const runtimeError = vi.fn();
const setStatus = vi.fn();
setWebhookSpy.mockRejectedValueOnce(new TypeError("fetch failed")).mockResolvedValueOnce(true);
await withStartedWebhook(
@@ -452,6 +469,7 @@ describe("startTelegramWebhook", () => {
secret: TELEGRAM_SECRET,
path: TELEGRAM_WEBHOOK_PATH,
runtime: { log: runtimeLog, error: runtimeError, exit: vi.fn() },
setStatus,
webhookRegistrationRetryPolicy: {
initialMs: 0,
maxMs: 0,
@@ -471,6 +489,18 @@ describe("startTelegramWebhook", () => {
expect(runtimeLog).toHaveBeenCalledWith(
expect.stringContaining("webhook advertised to telegram on http://"),
);
expect(setStatus).toHaveBeenCalledWith({
mode: "webhook",
connected: false,
lastError: "fetch failed",
});
expect(setStatus).toHaveBeenCalledWith(
expect.objectContaining({
mode: "webhook",
connected: true,
lastError: null,
}),
);
},
);
});
@@ -532,6 +562,7 @@ describe("startTelegramWebhook", () => {
it("invokes webhook handler on matching path", async () => {
handleUpdateSpy.mockClear();
createTelegramBotSpy.mockClear();
const setStatus = vi.fn();
const cfg = { bindings: [] };
await withStartedWebhook(
{
@@ -539,6 +570,7 @@ describe("startTelegramWebhook", () => {
accountId: "opie",
config: cfg,
path: TELEGRAM_WEBHOOK_PATH,
setStatus,
},
async ({ port }) => {
expect(createTelegramBotSpy).toHaveBeenCalledWith(
@@ -555,6 +587,13 @@ describe("startTelegramWebhook", () => {
});
expect(response.status).toBe(200);
await vi.waitFor(() => expect(handleUpdateSpy).toHaveBeenCalledWith(JSON.parse(payload)));
expect(setStatus).toHaveBeenCalledWith(
expect.objectContaining({
mode: "webhook",
connected: true,
lastError: null,
}),
);
},
);
});

View File

@@ -2,6 +2,7 @@ import { createServer } from "node:http";
import type { IncomingMessage } from "node:http";
import net from "node:net";
import * as grammy from "grammy";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { isDiagnosticsEnabled } from "openclaw/plugin-sdk/diagnostic-runtime";
import type { BackoffPolicy, RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@@ -35,6 +36,7 @@ import {
isTelegramRateLimitError,
isTelegramServerError,
} from "./network-errors.js";
import { createTelegramWebhookStatusPublisher } from "./webhook-status.js";
const TELEGRAM_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const TELEGRAM_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@@ -262,6 +264,7 @@ export async function startTelegramWebhook(opts: {
publicUrl?: string;
webhookCertPath?: string;
webhookRegistrationRetryPolicy?: BackoffPolicy;
setStatus?: (patch: Omit<ChannelAccountSnapshot, "accountId">) => void;
}) {
const path = opts.path ?? "/telegram-webhook";
const healthPath = opts.healthPath ?? "/healthz";
@@ -275,6 +278,8 @@ export async function startTelegramWebhook(opts: {
);
}
const runtime = opts.runtime ?? defaultRuntime;
const status = createTelegramWebhookStatusPublisher(opts.setStatus);
status.noteWebhookStart();
const webhookRegistrationRetryPolicy =
opts.webhookRegistrationRetryPolicy ?? TELEGRAM_WEBHOOK_REGISTRATION_RETRY_POLICY;
const diagnosticsEnabled = isDiagnosticsEnabled(opts.config);
@@ -365,6 +370,7 @@ export async function startTelegramWebhook(opts: {
}
respondText(200);
status.noteWebhookUpdateReceived();
void (async () => {
await bot.handleUpdate(body.value as Parameters<typeof bot.handleUpdate>[0]);
@@ -433,6 +439,7 @@ export async function startTelegramWebhook(opts: {
});
server.close();
void bot.stop();
status.noteWebhookStop();
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}
@@ -447,20 +454,26 @@ export async function startTelegramWebhook(opts: {
if (shutDown || opts.abortSignal?.aborted) {
return;
}
await withTelegramApiErrorLogging({
operation: "setWebhook",
runtime,
fn: () =>
bot.api.setWebhook(publicUrl, {
secret_token: secret,
allowed_updates: resolveTelegramAllowedUpdates(),
certificate: opts.webhookCertPath ? new InputFileCtor(opts.webhookCertPath) : undefined,
}),
});
try {
await withTelegramApiErrorLogging({
operation: "setWebhook",
runtime,
fn: () =>
bot.api.setWebhook(publicUrl, {
secret_token: secret,
allowed_updates: resolveTelegramAllowedUpdates(),
certificate: opts.webhookCertPath ? new InputFileCtor(opts.webhookCertPath) : undefined,
}),
});
} catch (err) {
status.noteWebhookRegistrationFailure(formatErrorMessage(err));
throw err;
}
if (shutDown) {
return;
}
webhookAdvertised = true;
status.noteWebhookAdvertised();
runtime.log?.(`webhook advertised to telegram on ${publicUrl}`);
};
const shouldRetryWebhookRegistration = (err: unknown): boolean =>
@@ -503,6 +516,7 @@ export async function startTelegramWebhook(opts: {
shutDown = true;
server.close();
void bot.stop();
status.noteWebhookStop();
if (diagnosticsEnabled) {
stopDiagnosticHeartbeat();
}

View File

@@ -223,6 +223,28 @@ describe("evaluateChannelHealth", () => {
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
});
it("keeps quiet telegram webhooks healthy when they do not publish transport tracking", () => {
const evaluation = evaluateChannelHealth(
{
running: true,
connected: true,
enabled: true,
configured: true,
mode: "webhook",
lastStartAt: 0,
lastConnectedAt: 0,
lastEventAt: 0,
},
{
channelId: "telegram",
now: 100_000,
channelConnectGraceMs: 10_000,
staleEventThresholdMs: 30_000,
},
);
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
});
it("does not flag stale sockets without an active connected socket", () => {
const evaluation = evaluateDiscordHealth(
{