mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(channels): improve health metadata and reply diagnostics
This commit is contained in:
@@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -118,6 +118,16 @@ function getCapturedDeliver() {
|
||||
)?.dispatcherOptions?.deliver;
|
||||
}
|
||||
|
||||
function getCapturedOnError() {
|
||||
return (
|
||||
capturedDispatchParams as {
|
||||
dispatcherOptions?: {
|
||||
onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
|
||||
};
|
||||
}
|
||||
)?.dispatcherOptions?.onError;
|
||||
}
|
||||
|
||||
type BufferedReplyParams = Parameters<typeof dispatchWhatsAppBufferedReply>[0];
|
||||
|
||||
function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
|
||||
@@ -704,6 +714,44 @@ describe("whatsapp inbound dispatch", () => {
|
||||
).toBe(sendComposing);
|
||||
});
|
||||
|
||||
it("logs delivery failures from the shared dispatcher with WhatsApp context", async () => {
|
||||
const replyLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
} as BufferedReplyParams["replyLogger"];
|
||||
const error = new Error("send failed");
|
||||
|
||||
await dispatchBufferedReply({
|
||||
connectionId: "conn-1",
|
||||
conversationId: "+15550001000",
|
||||
msg: makeMsg({
|
||||
id: "msg-1",
|
||||
from: "+15550001000",
|
||||
to: "+15550002000",
|
||||
chatId: "15550001000@s.whatsapp.net",
|
||||
}),
|
||||
replyLogger,
|
||||
});
|
||||
|
||||
getCapturedOnError()?.(error, { kind: "final" });
|
||||
|
||||
expect(replyLogger.error).toHaveBeenCalledWith(
|
||||
{
|
||||
err: error,
|
||||
replyKind: "final",
|
||||
correlationId: "msg-1",
|
||||
connectionId: "conn-1",
|
||||
conversationId: "+15550001000",
|
||||
chatId: "15550001000@s.whatsapp.net",
|
||||
to: "+15550001000",
|
||||
from: "+15550002000",
|
||||
},
|
||||
"auto-reply delivery failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates main last route for DM when session key matches main session key", () => {
|
||||
const updateLastRoute = vi.fn();
|
||||
|
||||
|
||||
@@ -56,6 +56,29 @@ type SenderContext = {
|
||||
e164?: string;
|
||||
};
|
||||
|
||||
function logWhatsAppReplyDeliveryError(params: {
|
||||
err: unknown;
|
||||
info: { kind: ReplyLifecycleKind };
|
||||
connectionId: string;
|
||||
conversationId: string;
|
||||
msg: WebInboundMsg;
|
||||
replyLogger: ReturnType<typeof getChildLogger>;
|
||||
}) {
|
||||
params.replyLogger.error(
|
||||
{
|
||||
err: params.err,
|
||||
replyKind: params.info.kind,
|
||||
correlationId: params.msg.id ?? null,
|
||||
connectionId: params.connectionId,
|
||||
conversationId: params.conversationId,
|
||||
chatId: params.msg.chatId ?? null,
|
||||
to: params.msg.from ?? null,
|
||||
from: params.msg.to ?? null,
|
||||
},
|
||||
"auto-reply delivery failed",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
|
||||
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
|
||||
return undefined;
|
||||
@@ -355,6 +378,16 @@ export async function dispatchWhatsAppBufferedReply(params: {
|
||||
}
|
||||
},
|
||||
onReplyStart: params.msg.sendComposing,
|
||||
onError: (err, info) => {
|
||||
logWhatsAppReplyDeliveryError({
|
||||
err,
|
||||
info,
|
||||
connectionId: params.connectionId,
|
||||
conversationId: params.conversationId,
|
||||
msg: params.msg,
|
||||
replyLogger: params.replyLogger,
|
||||
});
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
disableBlockStreaming,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { inspectChannelAccount } from "../account-inspection.js";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
|
||||
import type { ChannelPlugin } from "./types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "./types.public.js";
|
||||
|
||||
// Channel docking: status snapshots flow through plugin.status hooks here.
|
||||
async function buildSnapshotFromAccount<ResolvedAccount>(params: {
|
||||
export async function buildChannelAccountSnapshotFromAccount<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
@@ -14,52 +14,46 @@ async function buildSnapshotFromAccount<ResolvedAccount>(params: {
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
enabledFallback?: boolean;
|
||||
configuredFallback?: boolean;
|
||||
}): Promise<ChannelAccountSnapshot> {
|
||||
let snapshot: ChannelAccountSnapshot;
|
||||
if (params.plugin.status?.buildAccountSnapshot) {
|
||||
const snapshot = await params.plugin.status.buildAccountSnapshot({
|
||||
snapshot = await params.plugin.status.buildAccountSnapshot({
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
probe: params.probe,
|
||||
audit: params.audit,
|
||||
});
|
||||
return normalizeOptionalString(snapshot.accountId)
|
||||
? snapshot
|
||||
: {
|
||||
...snapshot,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
}
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(params.account, params.cfg)
|
||||
: params.account && typeof params.account === "object"
|
||||
? (params.account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
const configured =
|
||||
params.account && typeof params.account === "object" && "configured" in params.account
|
||||
? (params.account as { configured?: boolean }).configured
|
||||
: params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(params.account, params.cfg)
|
||||
} else {
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(params.account, params.cfg)
|
||||
: params.account && typeof params.account === "object"
|
||||
? (params.account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled,
|
||||
configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectChannelAccount<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): Promise<ResolvedAccount | null> {
|
||||
return (params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
|
||||
(await inspectReadOnlyChannelAccount({
|
||||
channelId: params.plugin.id,
|
||||
cfg: params.cfg,
|
||||
const configured =
|
||||
params.account && typeof params.account === "object" && "configured" in params.account
|
||||
? (params.account as { configured?: boolean }).configured
|
||||
: params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(params.account, params.cfg)
|
||||
: undefined;
|
||||
snapshot = {
|
||||
accountId: params.accountId,
|
||||
}))) as ResolvedAccount | null;
|
||||
enabled,
|
||||
configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
...projectSafeChannelAccountSnapshotFields(params.runtime),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
accountId: normalizeOptionalString(snapshot.accountId) ? snapshot.accountId : params.accountId,
|
||||
enabled: snapshot.enabled ?? params.enabledFallback,
|
||||
configured: snapshot.configured ?? params.configuredFallback,
|
||||
...(params.probe !== undefined && snapshot.probe === undefined ? { probe: params.probe } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
@@ -74,7 +68,7 @@ export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>
|
||||
if (!inspectedAccount) {
|
||||
return null;
|
||||
}
|
||||
return await buildSnapshotFromAccount({
|
||||
return await buildChannelAccountSnapshotFromAccount({
|
||||
...params,
|
||||
account: inspectedAccount as ResolvedAccount,
|
||||
});
|
||||
@@ -91,7 +85,7 @@ export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
const inspectedAccount = await inspectChannelAccount(params);
|
||||
const account =
|
||||
inspectedAccount ?? params.plugin.config.resolveAccount(params.cfg, params.accountId);
|
||||
return await buildSnapshotFromAccount({
|
||||
return await buildChannelAccountSnapshotFromAccount({
|
||||
...params,
|
||||
account,
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { createPluginRecord } from "../plugins/status.test-helpers.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
|
||||
let testConfig: Record<string, unknown> = {};
|
||||
let testStore: Record<string, { updatedAt?: number }> = {};
|
||||
let healthPluginsForTest: HealthTestPlugin[] = [];
|
||||
|
||||
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
|
||||
let createChannelTestPluginBase: typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
|
||||
@@ -18,6 +20,8 @@ let probeTelegramAccountForTestOverride:
|
||||
| ((account: TelegramHealthAccount, timeoutMs: number) => Promise<Record<string, unknown>>)
|
||||
| undefined;
|
||||
|
||||
type HealthTestPlugin = Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config" | "status">;
|
||||
|
||||
type TelegramHealthAccount = {
|
||||
accountId: string;
|
||||
token: string;
|
||||
@@ -29,6 +33,15 @@ type TelegramHealthAccount = {
|
||||
};
|
||||
};
|
||||
|
||||
type DiscordHealthAccount = {
|
||||
accountId: string;
|
||||
token: string;
|
||||
tokenSource: string;
|
||||
tokenStatus?: "available" | "configured_unavailable" | "missing";
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
async function loadFreshHealthModulesForTest() {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
getRuntimeConfig: () => testConfig,
|
||||
@@ -57,7 +70,7 @@ async function loadFreshHealthModulesForTest() {
|
||||
logoutWeb: vi.fn(),
|
||||
}));
|
||||
vi.doMock("../channels/plugins/read-only.js", () => ({
|
||||
listReadOnlyChannelPluginsForConfig: () => [createTelegramHealthPlugin()],
|
||||
listReadOnlyChannelPluginsForConfig: () => healthPluginsForTest,
|
||||
}));
|
||||
|
||||
const [pluginsRuntime, channelTestUtils, health] = await Promise.all([
|
||||
@@ -280,10 +293,7 @@ async function runSuccessfulTelegramProbe(
|
||||
return { calls, telegram };
|
||||
}
|
||||
|
||||
function createTelegramHealthPlugin(): Pick<
|
||||
ChannelPlugin,
|
||||
"id" | "meta" | "capabilities" | "config" | "status"
|
||||
> {
|
||||
function createTelegramHealthPlugin(): HealthTestPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }),
|
||||
config: {
|
||||
@@ -303,6 +313,91 @@ function createTelegramHealthPlugin(): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDiscordHealthAccountForTest(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
}): DiscordHealthAccount {
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const discord = (channels?.discord as Record<string, unknown> | undefined) ?? {};
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
const token = typeof discord.token === "string" ? discord.token.trim() : "";
|
||||
return {
|
||||
accountId,
|
||||
token,
|
||||
tokenSource: token ? "config" : "none",
|
||||
...(token ? { tokenStatus: "available" as const } : {}),
|
||||
enabled: discord.enabled !== false,
|
||||
configured: Boolean(token),
|
||||
};
|
||||
}
|
||||
|
||||
function inspectDiscordHealthAccountForTest(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
accountId?: string | null;
|
||||
}): DiscordHealthAccount {
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
const discord = (channels?.discord as Record<string, unknown> | undefined) ?? {};
|
||||
const accountId = params.accountId?.trim() || "default";
|
||||
const token = typeof discord.token === "string" ? discord.token.trim() : "";
|
||||
const tokenStatus =
|
||||
token.length > 0
|
||||
? "available"
|
||||
: discord.token && typeof discord.token === "object"
|
||||
? "configured_unavailable"
|
||||
: "missing";
|
||||
return {
|
||||
accountId,
|
||||
token,
|
||||
tokenSource: tokenStatus === "missing" ? "none" : "config",
|
||||
tokenStatus,
|
||||
enabled: discord.enabled !== false,
|
||||
configured: tokenStatus !== "missing",
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordHealthPlugin(): HealthTestPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({ id: "discord", label: "Discord" }),
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveDiscordHealthAccountForTest({
|
||||
cfg: cfg as Record<string, unknown>,
|
||||
accountId,
|
||||
}),
|
||||
inspectAccount: (cfg, accountId) =>
|
||||
inspectDiscordHealthAccountForTest({
|
||||
cfg: cfg as Record<string, unknown>,
|
||||
accountId,
|
||||
}),
|
||||
isEnabled: (account) => (account as DiscordHealthAccount).enabled,
|
||||
isConfigured: (account) => (account as DiscordHealthAccount).configured,
|
||||
},
|
||||
status: {
|
||||
buildAccountSnapshot: ({ account, runtime }) => {
|
||||
const resolved = account as DiscordHealthAccount;
|
||||
return {
|
||||
accountId: resolved.accountId,
|
||||
enabled: resolved.enabled,
|
||||
configured: resolved.configured,
|
||||
tokenSource: resolved.tokenSource,
|
||||
tokenStatus: resolved.tokenStatus,
|
||||
running: runtime?.running ?? false,
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
} satisfies ChannelAccountSnapshot;
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
tokenStatus: snapshot.tokenStatus,
|
||||
running: snapshot.running ?? false,
|
||||
connected: snapshot.connected ?? false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getHealthSnapshot", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -316,6 +411,7 @@ describe("getHealthSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
buildTelegramHealthSummaryForTest = buildTelegramHealthSummary;
|
||||
probeTelegramAccountForTestOverride = undefined;
|
||||
healthPluginsForTest = [createTelegramHealthPlugin()];
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "telegram", plugin: createTelegramHealthPlugin(), source: "test" },
|
||||
@@ -477,6 +573,111 @@ describe("getHealthSnapshot", () => {
|
||||
expect(telegram.accounts?.default?.probe?.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("merges inspected account metadata with runtime state before building health summaries", async () => {
|
||||
testConfig = { channels: { discord: { token: "discord-token" } } };
|
||||
testStore = {};
|
||||
healthPluginsForTest = [createDiscordHealthPlugin()];
|
||||
|
||||
const snap = await getHealthSnapshot({
|
||||
probe: false,
|
||||
includeSensitive: false,
|
||||
runtimeSnapshot: {
|
||||
channels: {
|
||||
discord: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
connected: true,
|
||||
lastConnectedAt: 123,
|
||||
},
|
||||
},
|
||||
channelAccounts: {},
|
||||
},
|
||||
});
|
||||
const discord = snap.channels.discord as {
|
||||
configured?: boolean;
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
tokenSource?: string;
|
||||
tokenStatus?: string;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{
|
||||
configured?: boolean;
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
tokenSource?: string;
|
||||
tokenStatus?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
expect(discord.configured).toBe(true);
|
||||
expect(discord.running).toBe(true);
|
||||
expect(discord.connected).toBe(true);
|
||||
expect(discord.tokenSource).toBe("config");
|
||||
expect(discord.tokenStatus).toBe("available");
|
||||
expect(discord.accounts?.default).toMatchObject({
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves plugin-derived configured state for unavailable SecretRef credentials", async () => {
|
||||
testConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_DISCORD_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
testStore = {};
|
||||
healthPluginsForTest = [createDiscordHealthPlugin()];
|
||||
|
||||
const snap = await getHealthSnapshot({
|
||||
probe: false,
|
||||
includeSensitive: false,
|
||||
runtimeSnapshot: {
|
||||
channels: {
|
||||
discord: {
|
||||
accountId: "default",
|
||||
running: true,
|
||||
connected: true,
|
||||
},
|
||||
},
|
||||
channelAccounts: {},
|
||||
},
|
||||
});
|
||||
const discord = snap.channels.discord as {
|
||||
configured?: boolean;
|
||||
tokenSource?: string;
|
||||
tokenStatus?: string;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{
|
||||
configured?: boolean;
|
||||
tokenSource?: string;
|
||||
tokenStatus?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
expect(discord.configured).toBe(true);
|
||||
expect(discord.tokenSource).toBe("config");
|
||||
expect(discord.tokenStatus).toBe("configured_unavailable");
|
||||
expect(discord.accounts?.default).toMatchObject({
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits secret runtime fields and raw probe payloads from non-sensitive health snapshots", async () => {
|
||||
testConfig = { channels: { telegram: { botToken: "t-1" } } };
|
||||
testStore = {};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "../channels/account-snapshot-fields.js";
|
||||
import { inspectChannelAccount } from "../channels/account-inspection.js";
|
||||
import {
|
||||
resolveChannelAccountConfigured,
|
||||
resolveChannelAccountEnabled,
|
||||
} from "../channels/account-summary.js";
|
||||
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
||||
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
|
||||
import { buildChannelAccountSnapshotFromAccount } from "../channels/plugins/status.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
@@ -176,17 +180,6 @@ function buildPluginHealthSummary(): PluginHealthSummary | undefined {
|
||||
return { loaded, errors };
|
||||
}
|
||||
|
||||
async function inspectHealthAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
|
||||
return (
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
(await inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function readBooleanField(value: unknown, key: string): boolean | undefined {
|
||||
const record = asNullableRecord(value);
|
||||
if (!record) {
|
||||
@@ -195,12 +188,60 @@ function readBooleanField(value: unknown, key: string): boolean | undefined {
|
||||
return typeof record[key] === "boolean" ? record[key] : undefined;
|
||||
}
|
||||
|
||||
const hasAccountValue = (account: unknown): boolean => account !== null && account !== undefined;
|
||||
|
||||
function resolveProbeAccountEnabled(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: unknown;
|
||||
diagnostics: string[];
|
||||
}): boolean {
|
||||
const fallback = readBooleanField(params.account, "enabled") ?? true;
|
||||
try {
|
||||
return resolveChannelAccountEnabled({
|
||||
plugin: params.plugin,
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
} catch (error) {
|
||||
params.diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveProbeAccountConfigured(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: unknown;
|
||||
diagnostics: string[];
|
||||
}): Promise<boolean> {
|
||||
const fallback = readBooleanField(params.account, "configured") ?? true;
|
||||
try {
|
||||
return await resolveChannelAccountConfigured({
|
||||
plugin: params.plugin,
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
readAccountConfiguredField: true,
|
||||
});
|
||||
} catch (error) {
|
||||
params.diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveHealthAccountContext(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): Promise<{
|
||||
account: unknown;
|
||||
probeAccount: unknown;
|
||||
snapshotAccount: unknown;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
diagnostics: string[];
|
||||
@@ -213,45 +254,50 @@ async function resolveHealthAccountContext(params: {
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
account = await inspectHealthAccount(params.plugin, params.cfg, params.accountId);
|
||||
}
|
||||
let inspectedAccount: unknown;
|
||||
try {
|
||||
inspectedAccount = await inspectChannelAccount(params);
|
||||
} catch (error) {
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to inspect account (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
const probeAccount = hasAccountValue(account) ? account : inspectedAccount;
|
||||
if (!hasAccountValue(probeAccount)) {
|
||||
return {
|
||||
account: {},
|
||||
probeAccount: {},
|
||||
snapshotAccount: {},
|
||||
enabled: false,
|
||||
configured: false,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
const snapshotAccount = hasAccountValue(inspectedAccount) ? inspectedAccount : probeAccount;
|
||||
|
||||
const enabledFallback = readBooleanField(account, "enabled") ?? true;
|
||||
let enabled = enabledFallback;
|
||||
if (params.plugin.config.isEnabled) {
|
||||
try {
|
||||
enabled = params.plugin.config.isEnabled(account, params.cfg);
|
||||
} catch (error) {
|
||||
enabled = enabledFallback;
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const enabled = resolveProbeAccountEnabled({
|
||||
plugin: params.plugin,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
account: probeAccount,
|
||||
diagnostics,
|
||||
});
|
||||
const configured = await resolveProbeAccountConfigured({
|
||||
plugin: params.plugin,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
account: probeAccount,
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
const configuredFallback = readBooleanField(account, "configured") ?? true;
|
||||
let configured = configuredFallback;
|
||||
if (params.plugin.config.isConfigured) {
|
||||
try {
|
||||
configured = await params.plugin.config.isConfigured(account, params.cfg);
|
||||
} catch (error) {
|
||||
configured = configuredFallback;
|
||||
diagnostics.push(
|
||||
`${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { account, enabled, configured, diagnostics };
|
||||
return {
|
||||
probeAccount,
|
||||
snapshotAccount,
|
||||
enabled,
|
||||
configured,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHealthSnapshot(params?: {
|
||||
@@ -332,11 +378,12 @@ export async function getHealthSnapshot(params?: {
|
||||
const accountSummaries: Record<string, ChannelAccountHealthSummary> = {};
|
||||
|
||||
for (const accountId of accountIdsToProbe) {
|
||||
const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const { probeAccount, snapshotAccount, enabled, configured, diagnostics } =
|
||||
await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
if (diagnostics.length > 0) {
|
||||
debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics });
|
||||
}
|
||||
@@ -346,7 +393,7 @@ export async function getHealthSnapshot(params?: {
|
||||
if (enabled && configured && doProbe && plugin.status?.probeAccount) {
|
||||
try {
|
||||
probe = await plugin.status.probeAccount({
|
||||
account,
|
||||
account: probeAccount,
|
||||
timeoutMs: cappedTimeout,
|
||||
cfg,
|
||||
});
|
||||
@@ -370,22 +417,23 @@ export async function getHealthSnapshot(params?: {
|
||||
const runtimeSnapshot =
|
||||
params?.runtimeSnapshot?.channelAccounts[plugin.id]?.[accountId] ??
|
||||
(accountId === defaultAccountId ? params?.runtimeSnapshot?.channels[plugin.id] : undefined);
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
...projectSafeChannelAccountSnapshotFields(runtimeSnapshot),
|
||||
const snapshot: ChannelAccountSnapshot = await buildChannelAccountSnapshotFromAccount({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
enabled,
|
||||
configured,
|
||||
};
|
||||
if (includeSensitive && probe !== undefined) {
|
||||
snapshot.probe = probe;
|
||||
}
|
||||
account: snapshotAccount,
|
||||
runtime: runtimeSnapshot,
|
||||
probe: includeSensitive ? probe : undefined,
|
||||
enabledFallback: enabled,
|
||||
configuredFallback: configured,
|
||||
});
|
||||
if (lastProbeAt) {
|
||||
snapshot.lastProbeAt = lastProbeAt;
|
||||
}
|
||||
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
? await plugin.status.buildChannelSummary({
|
||||
account,
|
||||
account: probeAccount,
|
||||
cfg,
|
||||
defaultAccountId: accountId,
|
||||
snapshot,
|
||||
@@ -530,12 +578,12 @@ export async function healthCommand(
|
||||
` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`,
|
||||
);
|
||||
for (const accountId of accountIds) {
|
||||
const { account, configured, diagnostics } = await resolveHealthAccountContext({
|
||||
const { snapshotAccount, configured, diagnostics } = await resolveHealthAccountContext({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
const record = asNullableRecord(account);
|
||||
const record = asNullableRecord(snapshotAccount);
|
||||
const tokenSource =
|
||||
record && typeof record.tokenSource === "string" ? record.tokenSource : undefined;
|
||||
runtime.log(
|
||||
@@ -650,7 +698,7 @@ export async function healthCommand(
|
||||
}
|
||||
try {
|
||||
plugin.status.logSelfId({
|
||||
account: accountContext.account,
|
||||
account: accountContext.probeAccount,
|
||||
cfg,
|
||||
runtime,
|
||||
includeChannelPrefix: true,
|
||||
|
||||
Reference in New Issue
Block a user