mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
test: narrow channel token summary coverage
This commit is contained in:
251
src/commands/status-all/channels-token-summary.ts
Normal file
251
src/commands/status-all/channels-token-summary.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
|
||||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||||
import { asRecord } from "../../shared/record-coerce.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
|
||||
export type ChannelAccountTokenSummaryRow = {
|
||||
account: unknown;
|
||||
enabled: boolean;
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
};
|
||||
|
||||
function summarizeSources(sources: Array<string | undefined>): {
|
||||
label: string;
|
||||
parts: string[];
|
||||
} {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of sources) {
|
||||
const key = s?.trim() ? s.trim() : "unknown";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const parts = [...counts.entries()]
|
||||
.toSorted((a, b) => b[1] - a[1])
|
||||
.map(([key, n]) => `${key}${n > 1 ? `×${n}` : ""}`);
|
||||
const label = parts.length > 0 ? parts.join("+") : "unknown";
|
||||
return { label, parts };
|
||||
}
|
||||
|
||||
function formatTokenHint(token: string, opts: { showSecrets: boolean }): string {
|
||||
const t = token.trim();
|
||||
if (!t) {
|
||||
return "empty";
|
||||
}
|
||||
if (!opts.showSecrets) {
|
||||
return `sha256:${sha256HexPrefix(t, 8)} · len ${t.length}`;
|
||||
}
|
||||
const head = t.slice(0, 4);
|
||||
const tail = t.slice(-4);
|
||||
if (t.length <= 10) {
|
||||
return `${t} · len ${t.length}`;
|
||||
}
|
||||
return `${head}…${tail} · len ${t.length}`;
|
||||
}
|
||||
|
||||
export function summarizeTokenConfig(params: {
|
||||
accounts: ChannelAccountTokenSummaryRow[];
|
||||
showSecrets: boolean;
|
||||
}): { state: "ok" | "setup" | "warn" | null; detail: string | null } {
|
||||
const enabled = params.accounts.filter((a) => a.enabled);
|
||||
if (enabled.length === 0) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountRecs = enabled.map((a) => asRecord(a.account));
|
||||
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
|
||||
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
|
||||
const hasSigningSecretField = accountRecs.some(
|
||||
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
|
||||
);
|
||||
const hasTokenField = accountRecs.some((r) => "token" in r);
|
||||
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountIsHttpMode = (rec: Record<string, unknown>) =>
|
||||
typeof rec.mode === "string" && rec.mode.trim() === "http";
|
||||
const hasCredentialAvailable = (
|
||||
rec: Record<string, unknown>,
|
||||
valueKey: string,
|
||||
statusKey: string,
|
||||
) => {
|
||||
const value = rec[valueKey];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return rec[statusKey] === "available";
|
||||
};
|
||||
|
||||
if (
|
||||
hasBotTokenField &&
|
||||
hasSigningSecretField &&
|
||||
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
|
||||
) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return (
|
||||
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
|
||||
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
|
||||
);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
|
||||
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
|
||||
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no credentials (need bot+signing)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const signingSources = summarizeSources(
|
||||
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
|
||||
);
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const signingHint = signingSecret.trim()
|
||||
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint =
|
||||
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField && hasAppTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
const app = normalizeOptionalString(rec.appToken) ?? "";
|
||||
return Boolean(bot) && Boolean(app);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
const app = normalizeOptionalString(rec.appToken) ?? "";
|
||||
const hasBot = Boolean(bot);
|
||||
const hasApp = Boolean(app);
|
||||
return (hasBot && !hasApp) || (!hasBot && hasApp);
|
||||
});
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial tokens (need bot+app) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no tokens (need bot+app)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const appSources = summarizeSources(ready.map((a) => a.snapshot.appTokenSource ?? "none"));
|
||||
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const appToken = typeof sample.appToken === "string" ? sample.appToken : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const appHint = appToken.trim()
|
||||
? formatTokenHint(appToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
|
||||
const hint = botHint || appHint ? ` (bot ${botHint || "?"}, app ${appHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `tokens ok (bot ${botSources.label}, app ${appSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
return Boolean(bot);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no bot token" };
|
||||
}
|
||||
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint = botHint ? ` (${botHint})` : "";
|
||||
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `bot token config${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return Boolean(normalizeOptionalString(rec.token));
|
||||
});
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no token" };
|
||||
}
|
||||
|
||||
const sources = summarizeSources(ready.map((a) => a.snapshot.tokenSource));
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const token = typeof sample.token === "string" ? sample.token : "";
|
||||
const hint = token.trim()
|
||||
? ` (${formatTokenHint(token, { showSecrets: params.showSecrets })})`
|
||||
: "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `token ${sources.label}${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
@@ -1,332 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import { makeDirectPlugin } from "../../test-utils/channel-plugin-test-fixtures.js";
|
||||
import { buildChannelsTable } from "./channels.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
|
||||
import {
|
||||
summarizeTokenConfig,
|
||||
type ChannelAccountTokenSummaryRow,
|
||||
} from "./channels-token-summary.js";
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeMattermostPlugin(): ChannelPlugin {
|
||||
function tokenRow(params: {
|
||||
account: Record<string, unknown>;
|
||||
snapshot?: Partial<ChannelAccountSnapshot>;
|
||||
enabled?: boolean;
|
||||
}): ChannelAccountTokenSummaryRow {
|
||||
return {
|
||||
id: "mattermost",
|
||||
meta: {
|
||||
id: "mattermost",
|
||||
label: "Mattermost",
|
||||
selectionLabel: "Mattermost",
|
||||
docsPath: "/channels/mattermost",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["echo"],
|
||||
defaultAccountId: () => "echo",
|
||||
resolveAccount: () => ({
|
||||
name: "Echo",
|
||||
enabled: true,
|
||||
botToken: "bot-token-value",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
},
|
||||
account: params.account,
|
||||
enabled: params.enabled ?? true,
|
||||
snapshot: {
|
||||
accountId: "primary",
|
||||
...params.snapshot,
|
||||
} as ChannelAccountSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
type TestTable = Awaited<ReturnType<typeof buildChannelsTable>>;
|
||||
|
||||
function makeSlackDirectPlugin(config: ChannelPlugin["config"]): ChannelPlugin {
|
||||
return makeDirectPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
config,
|
||||
});
|
||||
function summarize(accounts: ChannelAccountTokenSummaryRow[]) {
|
||||
return summarizeTokenConfig({ accounts, showSecrets: false });
|
||||
}
|
||||
|
||||
function createSlackTokenAccount(params?: { botToken?: string; appToken?: string }) {
|
||||
return {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
};
|
||||
}
|
||||
|
||||
function createUnavailableSlackTokenAccount() {
|
||||
return {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => createSlackTokenAccount(params),
|
||||
resolveAccount: () => createSlackTokenAccount(params),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUnavailableSlackPlugin(): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => createUnavailableSlackTokenAccount(),
|
||||
resolveAccount: () => createUnavailableSlackTokenAccount(),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
|
||||
return makeSlackDirectPlugin({
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? createUnavailableSlackTokenAccount()
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "none",
|
||||
appTokenSource: "none",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
|
||||
return makeDirectPlugin({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
describe("summarizeTokenConfig", () => {
|
||||
it("does not require appToken for bot-token-only channels", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
botToken: "bot-token-value",
|
||||
baseUrl: "https://mm.example.com",
|
||||
},
|
||||
snapshot: { botTokenSource: "config" },
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeHttpSlackUnavailablePlugin(): ChannelPlugin {
|
||||
return makeDirectPlugin({
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeTokenPlugin(): ChannelPlugin {
|
||||
return makeDirectPlugin({
|
||||
id: "token-only",
|
||||
label: "TokenOnly",
|
||||
docsPath: "/channels/token-only",
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
token: "token-value",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTestTable(
|
||||
plugins: ChannelPlugin[],
|
||||
params?: { cfg?: Record<string, unknown>; sourceConfig?: Record<string, unknown> },
|
||||
) {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue(plugins);
|
||||
return await buildChannelsTable((params?.cfg ?? { channels: {} }) as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: params?.sourceConfig as never,
|
||||
});
|
||||
}
|
||||
|
||||
function expectTableRow(
|
||||
table: TestTable,
|
||||
params: { id: string; state: string; detailContains?: string; detailEquals?: string },
|
||||
) {
|
||||
const row = table.rows.find((entry) => entry.id === params.id);
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.state).toBe(params.state);
|
||||
if (params.detailContains) {
|
||||
expect(row?.detail).toContain(params.detailContains);
|
||||
}
|
||||
if (params.detailEquals) {
|
||||
expect(row?.detail).toBe(params.detailEquals);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function expectTableDetailRows(
|
||||
table: TestTable,
|
||||
title: string,
|
||||
rows: Array<Record<string, string>>,
|
||||
) {
|
||||
const detail = table.details.find((entry) => entry.title === title);
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.rows).toEqual(rows);
|
||||
}
|
||||
|
||||
describe("buildChannelsTable - mattermost token summary", () => {
|
||||
it("does not require appToken for mattermost accounts", async () => {
|
||||
const table = await buildTestTable([makeMattermostPlugin()]);
|
||||
const mattermostRow = expectTableRow(table, { id: "mattermost", state: "ok" });
|
||||
expect(mattermostRow?.detail).not.toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("keeps bot+app requirement when both fields exist", async () => {
|
||||
const table = await buildTestTable([makeSlackPlugin({ botToken: "bot-token", appToken: "" })]);
|
||||
expectTableRow(table, { id: "slack", state: "warn", detailContains: "need bot+app" });
|
||||
});
|
||||
|
||||
it("reports configured-but-unavailable Slack credentials as warn", async () => {
|
||||
const table = await buildTestTable([makeUnavailableSlackPlugin()]);
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "unavailable in this command path",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unavailable credential state from the source config snapshot", async () => {
|
||||
const table = await buildTestTable([makeSourceAwareUnavailablePlugin()], {
|
||||
cfg: { marker: "resolved", channels: {} },
|
||||
sourceConfig: { marker: "source", channels: {} },
|
||||
});
|
||||
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "unavailable in this command path",
|
||||
});
|
||||
expectTableDetailRows(table, "Slack accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · app:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("ok");
|
||||
expect(summary.detail).toContain("bot token config");
|
||||
expect(summary.detail).not.toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("treats status-only available credentials as resolved", async () => {
|
||||
const table = await buildTestTable([makeSourceUnavailableResolvedAvailablePlugin()], {
|
||||
cfg: { marker: "resolved", channels: {} },
|
||||
sourceConfig: { marker: "source", channels: {} },
|
||||
});
|
||||
|
||||
expectTableRow(table, { id: "discord", state: "ok", detailEquals: "configured" });
|
||||
expectTableDetailRows(table, "Discord accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "token:config",
|
||||
Status: "OK",
|
||||
},
|
||||
it("keeps bot+app requirement when both fields exist", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
botToken: "bot-token",
|
||||
appToken: "",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("warn");
|
||||
expect(summary.detail).toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("treats Slack HTTP signing-secret availability as required config", async () => {
|
||||
const table = await buildTestTable([makeHttpSlackUnavailablePlugin()]);
|
||||
expectTableRow(table, {
|
||||
id: "slack",
|
||||
state: "warn",
|
||||
detailContains: "configured http credentials unavailable",
|
||||
});
|
||||
expectTableDetailRows(table, "Slack accounts", [
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · signing:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
it("reports configured-but-unavailable Slack credentials as warn", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
},
|
||||
snapshot: {
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("warn");
|
||||
expect(summary.detail).toContain("unavailable in this command path");
|
||||
});
|
||||
|
||||
it("still reports single-token channels as ok", async () => {
|
||||
const table = await buildTestTable([makeTokenPlugin()]);
|
||||
expectTableRow(table, { id: "token-only", state: "ok", detailContains: "token" });
|
||||
it("treats status-only available HTTP credentials as resolved", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
mode: "http",
|
||||
botToken: "",
|
||||
signingSecret: "", // pragma: allowlist secret
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "available", // pragma: allowlist secret
|
||||
},
|
||||
snapshot: {
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("ok");
|
||||
expect(summary.detail).toContain("credentials ok");
|
||||
});
|
||||
|
||||
it("treats Slack HTTP signing-secret availability as required config", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "", // pragma: allowlist secret
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
|
||||
},
|
||||
snapshot: {
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config", // pragma: allowlist secret
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("warn");
|
||||
expect(summary.detail).toContain("configured http credentials unavailable");
|
||||
});
|
||||
|
||||
it("still reports single-token channels as ok", () => {
|
||||
const summary = summarize([
|
||||
tokenRow({
|
||||
account: {
|
||||
token: "token-value",
|
||||
tokenSource: "config",
|
||||
},
|
||||
snapshot: { tokenSource: "config" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(summary.state).toBe("ok");
|
||||
expect(summary.detail).toContain("token config");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,12 @@ import type {
|
||||
} from "../../channels/plugins/types.public.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||||
import { asRecord } from "../../shared/record-coerce.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
summarizeTokenConfig,
|
||||
type ChannelAccountTokenSummaryRow,
|
||||
} from "./channels-token-summary.js";
|
||||
import { formatTimeAgo } from "./format.js";
|
||||
|
||||
export type ChannelRow = {
|
||||
@@ -31,12 +34,9 @@ export type ChannelRow = {
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type ChannelAccountRow = {
|
||||
type ChannelAccountRow = ChannelAccountTokenSummaryRow & {
|
||||
accountId: string;
|
||||
account: unknown;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
};
|
||||
|
||||
type ResolvedChannelAccountRowParams = {
|
||||
@@ -46,22 +46,6 @@ type ResolvedChannelAccountRowParams = {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
function summarizeSources(sources: Array<string | undefined>): {
|
||||
label: string;
|
||||
parts: string[];
|
||||
} {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of sources) {
|
||||
const key = s?.trim() ? s.trim() : "unknown";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const parts = [...counts.entries()]
|
||||
.toSorted((a, b) => b[1] - a[1])
|
||||
.map(([key, n]) => `${key}${n > 1 ? `×${n}` : ""}`);
|
||||
const label = parts.length > 0 ? parts.join("+") : "unknown";
|
||||
return { label, parts };
|
||||
}
|
||||
|
||||
function existsSyncMaybe(p: string | undefined): boolean | null {
|
||||
const path = normalizeOptionalString(p) ?? "";
|
||||
if (!path) {
|
||||
@@ -74,22 +58,6 @@ function existsSyncMaybe(p: string | undefined): boolean | null {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTokenHint(token: string, opts: { showSecrets: boolean }): string {
|
||||
const t = token.trim();
|
||||
if (!t) {
|
||||
return "empty";
|
||||
}
|
||||
if (!opts.showSecrets) {
|
||||
return `sha256:${sha256HexPrefix(t, 8)} · len ${t.length}`;
|
||||
}
|
||||
const head = t.slice(0, 4);
|
||||
const tail = t.slice(-4);
|
||||
if (t.length <= 10) {
|
||||
return `${t} · len ${t.length}`;
|
||||
}
|
||||
return `${head}…${tail} · len ${t.length}`;
|
||||
}
|
||||
|
||||
async function inspectChannelAccount(
|
||||
plugin: ChannelPlugin,
|
||||
cfg: OpenClawConfig,
|
||||
@@ -256,216 +224,6 @@ function collectMissingPaths(accounts: ChannelAccountRow[]): string[] {
|
||||
return missing;
|
||||
}
|
||||
|
||||
function summarizeTokenConfig(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accounts: ChannelAccountRow[];
|
||||
showSecrets: boolean;
|
||||
}): { state: "ok" | "setup" | "warn" | null; detail: string | null } {
|
||||
const enabled = params.accounts.filter((a) => a.enabled);
|
||||
if (enabled.length === 0) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountRecs = enabled.map((a) => asRecord(a.account));
|
||||
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
|
||||
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
|
||||
const hasSigningSecretField = accountRecs.some(
|
||||
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
|
||||
);
|
||||
const hasTokenField = accountRecs.some((r) => "token" in r);
|
||||
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountIsHttpMode = (rec: Record<string, unknown>) =>
|
||||
typeof rec.mode === "string" && rec.mode.trim() === "http";
|
||||
const hasCredentialAvailable = (
|
||||
rec: Record<string, unknown>,
|
||||
valueKey: string,
|
||||
statusKey: string,
|
||||
) => {
|
||||
const value = rec[valueKey];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return rec[statusKey] === "available";
|
||||
};
|
||||
|
||||
if (
|
||||
hasBotTokenField &&
|
||||
hasSigningSecretField &&
|
||||
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
|
||||
) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return (
|
||||
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
|
||||
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
|
||||
);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
|
||||
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
|
||||
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no credentials (need bot+signing)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const signingSources = summarizeSources(
|
||||
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
|
||||
);
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const signingHint = signingSecret.trim()
|
||||
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint =
|
||||
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField && hasAppTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
const app = normalizeOptionalString(rec.appToken) ?? "";
|
||||
return Boolean(bot) && Boolean(app);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
const app = normalizeOptionalString(rec.appToken) ?? "";
|
||||
const hasBot = Boolean(bot);
|
||||
const hasApp = Boolean(app);
|
||||
return (hasBot && !hasApp) || (!hasBot && hasApp);
|
||||
});
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial tokens (need bot+app) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no tokens (need bot+app)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const appSources = summarizeSources(ready.map((a) => a.snapshot.appTokenSource ?? "none"));
|
||||
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const appToken = typeof sample.appToken === "string" ? sample.appToken : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const appHint = appToken.trim()
|
||||
? formatTokenHint(appToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
|
||||
const hint = botHint || appHint ? ` (bot ${botHint || "?"}, app ${appHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `tokens ok (bot ${botSources.label}, app ${appSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = normalizeOptionalString(rec.botToken) ?? "";
|
||||
return Boolean(bot);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no bot token" };
|
||||
}
|
||||
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint = botHint ? ` (${botHint})` : "";
|
||||
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `bot token config${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return Boolean(normalizeOptionalString(rec.token));
|
||||
});
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no token" };
|
||||
}
|
||||
|
||||
const sources = summarizeSources(ready.map((a) => a.snapshot.tokenSource));
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const token = typeof sample.token === "string" ? sample.token : "";
|
||||
const hint = token.trim()
|
||||
? ` (${formatTokenHint(token, { showSecrets: params.showSecrets })})`
|
||||
: "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `token ${sources.label}${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
// `status --all` channels table.
|
||||
// Keep this generic: channel-specific rules belong in the channel plugin.
|
||||
export async function buildChannelsTable(
|
||||
@@ -530,8 +288,6 @@ export async function buildChannelsTable(
|
||||
const link = resolveLinkFields(summary);
|
||||
const missingPaths = collectMissingPaths(enabledAccounts);
|
||||
const tokenSummary = summarizeTokenConfig({
|
||||
plugin,
|
||||
cfg,
|
||||
accounts,
|
||||
showSecrets,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user