diff --git a/src/commands/status-all/channels-token-summary.ts b/src/commands/status-all/channels-token-summary.ts new file mode 100644 index 00000000000..7f430a784c8 --- /dev/null +++ b/src/commands/status-all/channels-token-summary.ts @@ -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): { + label: string; + parts: string[]; +} { + const counts = new Map(); + 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) => + typeof rec.mode === "string" && rec.mode.trim() === "http"; + const hasCredentialAvailable = ( + rec: Record, + 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}`, + }; +} diff --git a/src/commands/status-all/channels.mattermost-token-summary.test.ts b/src/commands/status-all/channels.mattermost-token-summary.test.ts index 3bf59d1104d..6682c77fea2 100644 --- a/src/commands/status-all/channels.mattermost-token-summary.test.ts +++ b/src/commands/status-all/channels.mattermost-token-summary.test.ts @@ -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; + snapshot?: Partial; + 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>; - -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; sourceConfig?: Record }, -) { - 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>, -) { - 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"); }); }); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index ba6f44b1899..7dd2442e5ae 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -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): { - label: string; - parts: string[]; -} { - const counts = new Map(); - 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) => - typeof rec.mode === "string" && rec.mode.trim() === "http"; - const hasCredentialAvailable = ( - rec: Record, - 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, });