Skip to content

Commit 4dd2768

Browse files
committed
fix(channels): improve health metadata and reply diagnostics
1 parent 1390ead commit 4dd2768

6 files changed

Lines changed: 432 additions & 106 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai
9292
- Gateway/hooks: keep successful `deliver:false` agent hooks silent, log a hook audit record for suppressed success announcements, and suppress fallback summaries after attempted hook delivery while still surfacing failed hook runs. Repairs #55761; builds on #36332 and #49234. Thanks @EffortlessSteven, @cioclawcode, and @BrennerSpear.
9393
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/[email protected]` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
9494
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
95+
- CLI/health: build channel health summaries from inspected credential metadata plus runtime state, so `openclaw health --json` reports Discord `running`, `connected`, and `tokenSource` consistently with channel status. Fixes #44354. Thanks @ferenc-acs.
9596
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.
9697
- Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash.
9798
- Agents/bootstrap: pass pending BOOTSTRAP.md contents through the first-run user prompt while keeping them out of privileged system context, and show limited bootstrap guidance when workspace file access is unavailable. Fixes #73622. Thanks @mark1010.
@@ -104,6 +105,7 @@ Docs: https://docs.openclaw.ai
104105
- Plugins/runtime-deps: cache bundled runtime-deps JSON/package files and root chunk import scans by file signature, reducing repeated staged-runtime scanning during bundled channel startup. Refs #73647 and #73705. Thanks @mattmcintyre and @bmilne1981.
105106
- CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab.
106107
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
108+
- Channels/WhatsApp: log shared dispatcher delivery failures with reply kind, message id, chat id, and connection id, so typing-without-send reports can identify whether the WhatsApp send path rejected a generated reply. Refs #74269. Thanks @tomcosta-git.
107109
- Feishu: suppress distinct late `final` text deliveries after a streaming card has already closed, while keeping media attachments deliverable, so late-finals no longer reopen duplicate Feishu cards. Fixes #71977. (#72294) Thanks @MonkeyLeeT.
108110
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
109111
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,16 @@ function getCapturedDeliver() {
118118
)?.dispatcherOptions?.deliver;
119119
}
120120

121+
function getCapturedOnError() {
122+
return (
123+
capturedDispatchParams as {
124+
dispatcherOptions?: {
125+
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
126+
};
127+
}
128+
)?.dispatcherOptions?.onError;
129+
}
130+
121131
type BufferedReplyParams = Parameters<typeof dispatchWhatsAppBufferedReply>[0];
122132

123133
function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
@@ -704,6 +714,44 @@ describe("whatsapp inbound dispatch", () => {
704714
).toBe(sendComposing);
705715
});
706716

717+
it("logs delivery failures from the shared dispatcher with WhatsApp context", async () => {
718+
const replyLogger = {
719+
info: vi.fn(),
720+
warn: vi.fn(),
721+
error: vi.fn(),
722+
debug: vi.fn(),
723+
} as BufferedReplyParams["replyLogger"];
724+
const error = new Error("send failed");
725+
726+
await dispatchBufferedReply({
727+
connectionId: "conn-1",
728+
conversationId: "+15550001000",
729+
msg: makeMsg({
730+
id: "msg-1",
731+
from: "+15550001000",
732+
to: "+15550002000",
733+
chatId: "[email protected]",
734+
}),
735+
replyLogger,
736+
});
737+
738+
getCapturedOnError()?.(error, { kind: "final" });
739+
740+
expect(replyLogger.error).toHaveBeenCalledWith(
741+
{
742+
err: error,
743+
replyKind: "final",
744+
correlationId: "msg-1",
745+
connectionId: "conn-1",
746+
conversationId: "+15550001000",
747+
chatId: "[email protected]",
748+
to: "+15550001000",
749+
from: "+15550002000",
750+
},
751+
"auto-reply delivery failed",
752+
);
753+
});
754+
707755
it("updates main last route for DM when session key matches main session key", () => {
708756
const updateLastRoute = vi.fn();
709757

extensions/whatsapp/src/auto-reply/monitor/inbound-dispatch.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ type SenderContext = {
5656
e164?: string;
5757
};
5858

59+
function logWhatsAppReplyDeliveryError(params: {
60+
err: unknown;
61+
info: { kind: ReplyLifecycleKind };
62+
connectionId: string;
63+
conversationId: string;
64+
msg: WebInboundMsg;
65+
replyLogger: ReturnType<typeof getChildLogger>;
66+
}) {
67+
params.replyLogger.error(
68+
{
69+
err: params.err,
70+
replyKind: params.info.kind,
71+
correlationId: params.msg.id ?? null,
72+
connectionId: params.connectionId,
73+
conversationId: params.conversationId,
74+
chatId: params.msg.chatId ?? null,
75+
to: params.msg.from ?? null,
76+
from: params.msg.to ?? null,
77+
},
78+
"auto-reply delivery failed",
79+
);
80+
}
81+
5982
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
6083
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
6184
return undefined;
@@ -355,6 +378,16 @@ export async function dispatchWhatsAppBufferedReply(params: {
355378
}
356379
},
357380
onReplyStart: params.msg.sendComposing,
381+
onError: (err, info) => {
382+
logWhatsAppReplyDeliveryError({
383+
err,
384+
info,
385+
connectionId: params.connectionId,
386+
conversationId: params.conversationId,
387+
msg: params.msg,
388+
replyLogger: params.replyLogger,
389+
});
390+
},
358391
},
359392
replyOptions: {
360393
disableBlockStreaming,

src/channels/plugins/status.ts

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,61 @@
11
import type { OpenClawConfig } from "../../config/types.openclaw.js";
22
import { normalizeOptionalString } from "../../shared/string-coerce.js";
3+
import { inspectChannelAccount } from "../account-inspection.js";
34
import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js";
4-
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
55
import type { ChannelPlugin } from "./types.plugin.js";
66
import type { ChannelAccountSnapshot } from "./types.public.js";
77

88
// Channel docking: status snapshots flow through plugin.status hooks here.
9-
async function buildSnapshotFromAccount<ResolvedAccount>(params: {
9+
export async function buildChannelAccountSnapshotFromAccount<ResolvedAccount>(params: {
1010
plugin: ChannelPlugin<ResolvedAccount>;
1111
cfg: OpenClawConfig;
1212
accountId: string;
1313
account: ResolvedAccount;
1414
runtime?: ChannelAccountSnapshot;
1515
probe?: unknown;
1616
audit?: unknown;
17+
enabledFallback?: boolean;
18+
configuredFallback?: boolean;
1719
}): Promise<ChannelAccountSnapshot> {
20+
let snapshot: ChannelAccountSnapshot;
1821
if (params.plugin.status?.buildAccountSnapshot) {
19-
const snapshot = await params.plugin.status.buildAccountSnapshot({
22+
snapshot = await params.plugin.status.buildAccountSnapshot({
2023
account: params.account,
2124
cfg: params.cfg,
2225
runtime: params.runtime,
2326
probe: params.probe,
2427
audit: params.audit,
2528
});
26-
return normalizeOptionalString(snapshot.accountId)
27-
? snapshot
28-
: {
29-
...snapshot,
30-
accountId: params.accountId,
31-
};
32-
}
33-
const enabled = params.plugin.config.isEnabled
34-
? params.plugin.config.isEnabled(params.account, params.cfg)
35-
: params.account && typeof params.account === "object"
36-
? (params.account as { enabled?: boolean }).enabled
37-
: undefined;
38-
const configured =
39-
params.account && typeof params.account === "object" && "configured" in params.account
40-
? (params.account as { configured?: boolean }).configured
41-
: params.plugin.config.isConfigured
42-
? await params.plugin.config.isConfigured(params.account, params.cfg)
29+
} else {
30+
const enabled = params.plugin.config.isEnabled
31+
? params.plugin.config.isEnabled(params.account, params.cfg)
32+
: params.account && typeof params.account === "object"
33+
? (params.account as { enabled?: boolean }).enabled
4334
: undefined;
35+
const configured =
36+
params.account && typeof params.account === "object" && "configured" in params.account
37+
? (params.account as { configured?: boolean }).configured
38+
: params.plugin.config.isConfigured
39+
? await params.plugin.config.isConfigured(params.account, params.cfg)
40+
: undefined;
41+
snapshot = {
42+
accountId: params.accountId,
43+
enabled,
44+
configured,
45+
...projectSafeChannelAccountSnapshotFields(params.account),
46+
...projectSafeChannelAccountSnapshotFields(params.runtime),
47+
};
48+
}
49+
4450
return {
45-
accountId: params.accountId,
46-
enabled,
47-
configured,
48-
...projectSafeChannelAccountSnapshotFields(params.account),
51+
...snapshot,
52+
accountId: normalizeOptionalString(snapshot.accountId) ? snapshot.accountId : params.accountId,
53+
enabled: snapshot.enabled ?? params.enabledFallback,
54+
configured: snapshot.configured ?? params.configuredFallback,
55+
...(params.probe !== undefined && snapshot.probe === undefined ? { probe: params.probe } : {}),
4956
};
5057
}
5158

52-
async function inspectChannelAccount<ResolvedAccount>(params: {
53-
plugin: ChannelPlugin<ResolvedAccount>;
54-
cfg: OpenClawConfig;
55-
accountId: string;
56-
}): Promise<ResolvedAccount | null> {
57-
return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
58-
(await inspectReadOnlyChannelAccount({
59-
channelId: params.plugin.id,
60-
cfg: params.cfg,
61-
accountId: params.accountId,
62-
}))) as ResolvedAccount | null;
63-
}
64-
6559
export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>(params: {
6660
plugin: ChannelPlugin<ResolvedAccount>;
6761
cfg: OpenClawConfig;
@@ -74,7 +68,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>
7468
if (!inspectedAccount) {
7569
return null;
7670
}
77-
return await buildSnapshotFromAccount({
71+
return await buildChannelAccountSnapshotFromAccount({
7872
...params,
7973
account: inspectedAccount as ResolvedAccount,
8074
});
@@ -91,7 +85,7 @@ export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
9185
const inspectedAccount = await inspectChannelAccount(params);
9286
const account =
9387
inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId);
94-
return await buildSnapshotFromAccount({
88+
return await buildChannelAccountSnapshotFromAccount({
9589
...params,
9690
account,
9791
});

0 commit comments

Comments
 (0)