diff --git a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh index f44157f3a953..7e8ca4096a36 100755 --- a/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-upgrade-user-journey/scenario.sh @@ -34,6 +34,7 @@ PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log" AGENT_LOG="$LOG_DIR/agent.log" STATUS_JSON="$LOG_DIR/status.json" STATUS_ERR="$LOG_DIR/status.err" +CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log" CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json" CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err" CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log" @@ -77,6 +78,7 @@ dump_debug_logs() { "$PLUGIN_CLI_AFTER_LOG" \ "$AGENT_LOG" \ "$STATUS_JSON" \ + "$CLICKCLACK_PLUGIN_INSTALL_LOG" \ "$CLICKCLACK_OUTBOUND_JSON" \ "$CLICKCLACK_SERVER_LOG" \ "$GATEWAY_LOG" \ @@ -162,6 +164,10 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCES openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1 node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong" +clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" +node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" +openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 + openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR" node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON" openclaw message send \ diff --git a/scripts/e2e/lib/release-user-journey/scenario.sh b/scripts/e2e/lib/release-user-journey/scenario.sh index d888b49ce8b4..361b27acffc8 100755 --- a/scripts/e2e/lib/release-user-journey/scenario.sh +++ b/scripts/e2e/lib/release-user-journey/scenario.sh @@ -35,6 +35,7 @@ PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log" PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log" PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log" PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json" +CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log" CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log" CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json" CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err" @@ -77,6 +78,7 @@ dump_debug_logs() { "$PLUGIN_A_UNINSTALL_LOG" \ "$PLUGIN_B_INSTALL_LOG" \ "$PLUGIN_B_CLI_LOG" \ + "$CLICKCLACK_PLUGIN_INSTALL_LOG" \ "$CLICKCLACK_SERVER_LOG" \ "$CLICKCLACK_OUTBOUND_JSON" \ "$GATEWAY_1_LOG" \ @@ -215,6 +217,11 @@ openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1 openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1 node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong" +echo "Installing ClickClack fixture plugin..." +clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")" +node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir" +openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1 + echo "Configuring ClickClack..." node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT" openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR" diff --git a/scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs b/scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs new file mode 100644 index 000000000000..73230f3d5e84 --- /dev/null +++ b/scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node +// Writes the external ClickClack channel fixture used by release journey E2Es. +import fs from "node:fs"; +import path from "node:path"; + +const pluginDir = process.argv[2]; +if (!pluginDir) { + console.error("usage: write-clickclack-plugin.mjs "); + process.exit(2); +} + +fs.mkdirSync(pluginDir, { recursive: true }); +fs.writeFileSync( + path.join(pluginDir, "package.json"), + `${JSON.stringify( + { + name: "clickclack", + version: "0.0.1", + type: "module", + openclaw: { extensions: ["./index.mjs"] }, + }, + null, + 2, + )}\n`, +); +fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + `${JSON.stringify( + { + id: "clickclack", + activation: { onStartup: false }, + channels: ["clickclack"], + channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] }, + channelConfigs: { + clickclack: { + schema: { + type: "object", + additionalProperties: true, + properties: { + enabled: { type: "boolean", default: true }, + baseUrl: { type: "string" }, + workspace: { type: "string" }, + defaultTo: { type: "string" }, + token: {}, + }, + }, + }, + }, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, + null, + 2, + )}\n`, +); + +fs.writeFileSync( + path.join(pluginDir, "index.mjs"), + `import crypto from "node:crypto"; +import net from "node:net"; + +const CHANNEL_ID = "clickclack"; +const DEFAULT_ACCOUNT_ID = "default"; + +function configFor(cfg) { + return cfg?.channels?.clickclack ?? {}; +} + +function readToken(raw) { + if (typeof raw === "string") { + return raw.trim(); + } + if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") { + return String(process.env[raw.id] ?? "").trim(); + } + return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim(); +} + +function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) { + const config = configFor(cfg); + const token = readToken(config.token); + const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : ""; + return { + accountId: accountId ?? DEFAULT_ACCOUNT_ID, + enabled: config.enabled !== false, + configured: Boolean(baseUrl && token), + baseUrl, + token, + workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release", + defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general", + reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250, + }; +} + +async function requestJson(account, method, pathname, body) { + const response = await fetch(new URL(pathname, account.baseUrl), { + method, + headers: { + authorization: \`Bearer \${account.token}\`, + ...(body == null ? {} : { "content-type": "application/json" }), + }, + ...(body == null ? {} : { body: JSON.stringify(body) }), + }); + if (!response.ok) { + throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`); + } + return await response.json(); +} + +async function resolveWorkspaceId(account) { + const data = await requestJson(account, "GET", "/api/workspaces"); + const workspaces = Array.isArray(data.workspaces) ? data.workspaces : []; + const match = workspaces.find((workspace) => + workspace?.id === account.workspace || + workspace?.slug === account.workspace || + workspace?.name === account.workspace + ); + if (!match?.id) { + throw new Error(\`ClickClack workspace not found: \${account.workspace}\`); + } + return match.id; +} + +async function resolveChannelId(account, workspaceId, rawTarget) { + const target = String(rawTarget ?? "").trim(); + const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target; + const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`); + const channels = Array.isArray(data.channels) ? data.channels : []; + const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName); + if (!match?.id) { + throw new Error(\`ClickClack channel not found: \${channelName}\`); + } + return match.id; +} + +async function sendText(cfg, to, text, accountId, threadId, replyToId) { + const account = resolveAccount(cfg, accountId); + if (!account.configured) { + throw new Error("ClickClack is not configured"); + } + const workspaceId = await resolveWorkspaceId(account); + const rootId = threadId == null ? String(replyToId ?? "") : String(threadId); + if (rootId) { + const data = await requestJson( + account, + "POST", + \`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`, + { body: text }, + ); + return data.message; + } + const channelId = await resolveChannelId(account, workspaceId, to); + const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, { + body: text, + }); + return data.message; +} + +function decodeFrame(buffer) { + if (buffer.length < 2) { + return null; + } + const opcode = buffer[0] & 0x0f; + let length = buffer[1] & 0x7f; + let offset = 2; + if (length === 126) { + if (buffer.length < 4) { + return null; + } + length = buffer.readUInt16BE(2); + offset = 4; + } else if (length === 127) { + if (buffer.length < 10) { + return null; + } + length = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + if (buffer.length < offset + length) { + return null; + } + return { + opcode, + text: buffer.subarray(offset, offset + length).toString("utf8"), + rest: buffer.subarray(offset + length), + }; +} + +function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) { + const base = new URL(account.baseUrl); + const key = crypto.randomBytes(16).toString("base64"); + const socket = net.createConnection({ + host: base.hostname, + port: Number(base.port || (base.protocol === "https:" ? 443 : 80)), + }); + let buffer = Buffer.alloc(0); + let upgraded = false; + const close = () => socket.destroy(); + signal.addEventListener("abort", close, { once: true }); + socket.on("connect", () => { + const query = new URLSearchParams({ workspace_id: workspaceId }); + if (afterCursor) { + query.set("after_cursor", afterCursor); + } + socket.write( + [ + \`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`, + \`Host: \${base.host}\`, + "Upgrade: websocket", + "Connection: Upgrade", + \`Sec-WebSocket-Key: \${key}\`, + "Sec-WebSocket-Version: 13", + \`Authorization: Bearer \${account.token}\`, + "", + "", + ].join("\\r\\n"), + ); + }); + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!upgraded) { + const headerEnd = buffer.indexOf("\\r\\n\\r\\n"); + if (headerEnd === -1) { + return; + } + const headers = buffer.subarray(0, headerEnd).toString("utf8"); + if (!headers.startsWith("HTTP/1.1 101")) { + socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed")); + return; + } + upgraded = true; + buffer = buffer.subarray(headerEnd + 4); + } + for (;;) { + const frame = decodeFrame(buffer); + if (!frame) { + return; + } + buffer = frame.rest; + if (frame.opcode === 1) { + onEvent(JSON.parse(frame.text)); + } else if (frame.opcode === 8) { + socket.end(); + return; + } + } + }); + socket.on("close", () => signal.removeEventListener("abort", close)); + return socket; +} + +async function resolveEventMessage(account, event) { + if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") { + return null; + } + const data = await requestJson( + account, + "GET", + \`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`, + ); + const messages = Array.isArray(data.messages) ? data.messages : []; + return messages.find((message) => message?.id === event.payload?.message_id) ?? null; +} + +async function dispatchInbound(ctx, account, message) { + const runtime = ctx.channelRuntime; + if (!runtime) { + throw new Error("ClickClack fixture requires channel runtime"); + } + const target = \`channel:\${message.channel_id}\`; + const route = runtime.routing.resolveAgentRoute({ + cfg: ctx.cfg, + channel: CHANNEL_ID, + accountId: account.accountId, + peer: { kind: "channel", id: target }, + }); + const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, { + agentId: route.agentId, + }); + const previousTimestamp = runtime.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const senderName = message.author?.display_name || message.author_id || "Release User"; + const body = runtime.reply.formatAgentEnvelope({ + channel: "ClickClack", + from: senderName, + timestamp: new Date(message.created_at), + previousTimestamp, + envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg), + body: message.body, + }); + const ctxPayload = runtime.reply.finalizeInboundContext({ + Body: body, + BodyForAgent: message.body, + RawBody: message.body, + CommandBody: message.body, + From: target, + To: target, + SessionKey: route.sessionKey, + AccountId: route.accountId ?? account.accountId, + ChatType: "group", + WasMentioned: true, + ConversationLabel: message.channel_id, + GroupChannel: message.channel_id, + NativeChannelId: message.channel_id, + MessageSid: message.id, + MessageSidFull: message.id, + ReplyToId: message.id, + Timestamp: message.created_at, + OriginatingChannel: CHANNEL_ID, + OriginatingTo: target, + CommandAuthorized: true, + }); + await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: ctx.cfg, + dispatcherOptions: { + deliver: async (payload) => { + const text = payload && typeof payload === "object" ? String(payload.text ?? "") : ""; + if (text.trim()) { + await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id); + } + }, + onError: (error) => { + throw error instanceof Error ? error : new Error(String(error)); + }, + }, + }); +} + +const clickclackPlugin = { + id: CHANNEL_ID, + meta: { + id: CHANNEL_ID, + label: "ClickClack", + selectionLabel: "ClickClack", + docsPath: "/channels/clickclack", + blurb: "Release journey ClickClack fixture.", + }, + capabilities: { chatTypes: ["group"], threads: true }, + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + resolveAccount, + isConfigured: (account) => account.configured, + isEnabled: (account) => account.enabled, + resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo, + }, + status: { + buildChannelSummary: ({ snapshot }) => ({ + ok: snapshot.configured === true, + label: snapshot.configured ? "configured" : "missing config", + detail: snapshot.baseUrl ?? "", + }), + buildAccountSnapshot: ({ account }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + }, + outbound: { + deliveryMode: "direct", + sendText: async (ctx) => { + const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId); + return { channel: CHANNEL_ID, messageId: message.id }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = resolveAccount(ctx.cfg, ctx.account.accountId); + if (!account.configured) { + throw new Error("ClickClack is not configured"); + } + const workspaceId = await resolveWorkspaceId(account); + ctx.setStatus({ + accountId: account.accountId, + running: true, + configured: true, + enabled: account.enabled, + baseUrl: account.baseUrl, + }); + try { + while (!ctx.abortSignal.aborted) { + const socket = openEventSocket( + account, + workspaceId, + "", + (event) => { + void (async () => { + const message = await resolveEventMessage(account, event); + if (message && message.author?.kind !== "bot") { + await dispatchInbound(ctx, account, message); + } + })().catch((error) => { + ctx.log?.error?.(error instanceof Error ? error.message : String(error)); + }); + }, + ctx.abortSignal, + ); + await new Promise((resolve) => { + socket.once("close", resolve); + socket.once("error", resolve); + }); + if (!ctx.abortSignal.aborted) { + await new Promise((resolve) => setTimeout(resolve, account.reconnectMs)); + } + } + } finally { + ctx.setStatus({ accountId: account.accountId, running: false }); + } + }, + }, +}; + +export default { + id: CHANNEL_ID, + register(api) { + api.registerChannel({ plugin: clickclackPlugin }); + }, +}; +`, +); diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 6fc402cd3498..aa7356d09d55 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })), requireValidConfigSnapshot: vi.fn(), listChannelPlugins: vi.fn(), - listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]), + listConfiguredAnnounceChannelIdsForConfig: vi.fn((_params: unknown) => ["discord"]), missingOfficialExternalChannels: new Set(), withProgress: vi.fn(async (_opts: unknown, run: () => Promise) => await run()), })); @@ -41,8 +41,8 @@ vi.mock("../config/config.js", () => ({ vi.mock("../plugins/channel-plugin-ids.js", () => ({ listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record }) => Object.keys(config.channels ?? {}), - listConfiguredChannelIdsForReadOnlyScope: (params: unknown) => - mocks.listConfiguredChannelIdsForReadOnlyScope(params), + listConfiguredAnnounceChannelIdsForConfig: (params: unknown) => + mocks.listConfiguredAnnounceChannelIdsForConfig(params), })); vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ @@ -108,7 +108,8 @@ vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: () => mocks.listChannelPlugins(), getChannelPlugin: (channel: string) => (mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), - normalizeChannelId: (channel: string) => (channel === "imsg" ? "imessage" : channel), + normalizeChannelId: (channel: string) => + channel === "clickclack" ? undefined : channel === "imsg" ? "imessage" : channel, })); vi.mock("../channels/plugins/read-only.js", () => ({ @@ -207,8 +208,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { mocks.requireValidConfigSnapshot.mockReset(); mocks.listChannelPlugins.mockReset(); mocks.missingOfficialExternalChannels.clear(); - mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear(); - mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]); + mocks.listConfiguredAnnounceChannelIdsForConfig.mockClear(); + mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["discord"]); mocks.withProgress.mockClear(); mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); }); @@ -355,13 +356,15 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { await channelsStatusCommand({ channel: "imsg", json: true, probe: false }, runtime as never); expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); - expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledOnce(); - const readOnlyScopeRequest = mocks.listConfiguredChannelIdsForReadOnlyScope.mock - .calls[0]?.[0] as - | { config?: { secretResolved?: unknown }; includePersistedAuthState?: unknown } + expect(mocks.listConfiguredAnnounceChannelIdsForConfig).toHaveBeenCalledOnce(); + const announceRequest = mocks.listConfiguredAnnounceChannelIdsForConfig.mock.calls[0]?.[0] as + | { + config?: { secretResolved?: unknown }; + activationSourceConfig?: { secretResolved?: unknown }; + } | undefined; - expect(readOnlyScopeRequest?.config?.secretResolved).toBe(true); - expect(readOnlyScopeRequest?.includePersistedAuthState).toBe(false); + expect(announceRequest?.config?.secretResolved).toBe(true); + expect(announceRequest?.activationSourceConfig?.secretResolved).toBe(false); const payload = JSON.parse(logs.at(-1) ?? "{}"); expect(errors.join("\n")).not.toContain("user:pass"); expect(errors.join("\n")).not.toContain("secret-token"); @@ -378,6 +381,53 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(payload.configuredChannels).toStrictEqual([]); }); + it("includes explicitly configured channels in JSON config-only fallback", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.requireValidConfigSnapshot.mockResolvedValue({ + channels: { clickclack: { enabled: true } }, + }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { channels: { clickclack: { enabled: true } } }, + effectiveConfig: { channels: { clickclack: { enabled: true } } }, + diagnostics: [], + }); + mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["clickclack"]); + const { runtime, logs } = createCapturingTestRuntime(); + + await channelsStatusCommand({ json: true, probe: false }, runtime as never); + + const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(payload.gatewayReachable).toBe(false); + expect(payload.configOnly).toBe(true); + expect(payload.configuredChannels).toStrictEqual(["clickclack"]); + }); + + it("filters explicitly configured channels in JSON config-only fallback", async () => { + mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.requireValidConfigSnapshot.mockResolvedValue({ + channels: { clickclack: { enabled: true }, telegram: { enabled: true } }, + }); + mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ + resolvedConfig: { + channels: { clickclack: { enabled: true }, telegram: { enabled: true } }, + }, + effectiveConfig: { + channels: { clickclack: { enabled: true }, telegram: { enabled: true } }, + }, + diagnostics: [], + }); + mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["clickclack", "telegram"]); + const { runtime, logs } = createCapturingTestRuntime(); + + await channelsStatusCommand( + { channel: "clickclack", json: true, probe: false }, + runtime as never, + ); + + const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(payload.configuredChannels).toStrictEqual(["clickclack"]); + }); + it("rejects invalid timeout before falling back to config-only status", async () => { const { runtime } = createCapturingTestRuntime(); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 7e071e378acc..39f9c1a5a765 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,5 +1,6 @@ // Implements `openclaw channels status` with gateway status and config-only fallback. import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; +import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; @@ -14,7 +15,7 @@ import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; -import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js"; +import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { appendBaseUrlBit, @@ -222,7 +223,9 @@ export async function channelsStatusCommand( const timeoutMs = parseTimeoutMsWithFallback(opts.timeout, opts.probe ? 30_000 : 10_000, { invalidType: "error", }); - const requestedChannel = opts.channel ? normalizeChannelId(opts.channel) : null; + const requestedChannel = opts.channel + ? (normalizeChannelId(opts.channel) ?? normalizeOptionalLowercaseString(opts.channel)) + : null; const statusLabel = opts.probe ? "Checking channel status (probe)…" : "Checking channel status…"; const shouldLogStatus = opts.json !== true && !process.stderr.isTTY; if (shouldLogStatus) { @@ -287,11 +290,10 @@ export async function channelsStatusCommand( path: snapshot.path, mode, }, - configuredChannels: listConfiguredChannelIdsForReadOnlyScope({ + configuredChannels: listConfiguredAnnounceChannelIdsForConfig({ config: resolvedConfig, activationSourceConfig: cfg, env: process.env, - includePersistedAuthState: false, }).filter((channelId) => !requestedChannel || channelId === requestedChannel), }); return; diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index f0832325f0e1..a574e4aa096a 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -27,11 +27,11 @@ import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js" import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { listConfiguredMessageChannels } from "../../infra/outbound/channel-selection.js"; import { resolveTargetPrefixedChannel, validateTargetProviderPrefix, } from "../../infra/outbound/channel-target-prefix.js"; -import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; @@ -63,14 +63,11 @@ function compactCronListJob(job: CronJob) { }; } -function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): string[] { - return listConfiguredAnnounceChannelIdsForConfig({ - config: cfg, - env: process.env, - }); +async function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): Promise { + return await listConfiguredMessageChannels(cfg); } -function assertConfiguredAnnounceChannel(params: { +async function assertConfiguredAnnounceChannel(params: { cfg: OpenClawConfig; channel?: string; field: "delivery.channel" | "delivery.failureDestination.channel"; @@ -81,7 +78,7 @@ function assertConfiguredAnnounceChannel(params: { return; } - const configuredChannels = listConfiguredAnnounceChannelIds(params.cfg).toSorted(); + const configuredChannels = (await listConfiguredAnnounceChannelIds(params.cfg)).toSorted(); const normalizedChannel = normalizeMessageChannel(params.channel); if (!normalizedChannel) { if (configuredChannels.length <= 1) { @@ -93,7 +90,7 @@ function assertConfiguredAnnounceChannel(params: { } if (configuredChannels.length === 0) { - return; + throw new Error(`${params.field} is not configured: ${normalizedChannel}`); } if (configuredChannels.includes(normalizedChannel)) { @@ -132,14 +129,17 @@ function assertCompatibleAnnounceTarget(params: { } } -function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery?: CronDelivery }) { +async function assertValidCronAnnounceDelivery(params: { + cfg: OpenClawConfig; + delivery?: CronDelivery; +}) { if (params.delivery && (params.delivery.mode ?? "announce") === "announce") { assertCompatibleAnnounceTarget({ channel: params.delivery.channel, to: params.delivery.to, field: "delivery.channel", }); - assertConfiguredAnnounceChannel({ + await assertConfiguredAnnounceChannel({ cfg: params.cfg, channel: resolveAnnounceValidationChannel({ channel: params.delivery.channel, @@ -164,7 +164,7 @@ function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery to: failureDestination.to, field: "delivery.failureDestination.channel", }); - assertConfiguredAnnounceChannel({ + await assertConfiguredAnnounceChannel({ cfg: params.cfg, channel: resolveAnnounceValidationChannel({ channel: failureDestination.channel, @@ -175,14 +175,14 @@ function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery } } -function assertValidCronCreateDelivery(cfg: OpenClawConfig, jobCreate: CronJobCreate) { - assertValidCronAnnounceDelivery({ +async function assertValidCronCreateDelivery(cfg: OpenClawConfig, jobCreate: CronJobCreate) { + await assertValidCronAnnounceDelivery({ cfg, delivery: jobCreate.delivery, }); } -function assertValidCronUpdatePatch(params: { +async function assertValidCronUpdatePatch(params: { cfg: OpenClawConfig; defaultAgentId?: string; currentJob: CronJob; @@ -204,7 +204,7 @@ function assertValidCronUpdatePatch(params: { resolveTargetPrefixedChannel(nextJob.delivery.to) === undefined ? { ...nextJob.delivery, channel: "last" as const } : nextJob.delivery; - assertValidCronAnnounceDelivery({ + await assertValidCronAnnounceDelivery({ cfg: params.cfg, delivery, }); @@ -459,7 +459,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } try { - assertValidCronCreateDelivery(cfg, jobCreate); + await assertValidCronCreateDelivery(cfg, jobCreate); } catch (err) { respond( false, @@ -564,7 +564,7 @@ export const cronHandlers: GatewayRequestHandlers = { } } try { - assertValidCronUpdatePatch({ + await assertValidCronUpdatePatch({ cfg, defaultAgentId: context.cron.getDefaultAgentId(), currentJob, diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index d76e1f5e2020..6a11979172e9 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -30,7 +30,15 @@ function createPrefixOnlyChannelPlugin( targetPrefixes: readonly string[], aliases?: readonly string[], ): ChannelPlugin { - const base = createChannelTestPluginBase({ id }); + const base = createChannelTestPluginBase({ + id, + config: { + isConfigured: (_account, cfg) => { + const channelConfig = cfg.channels?.[id]; + return Boolean(channelConfig && channelConfig.enabled !== false); + }, + }, + }); return { ...base, meta: { @@ -495,6 +503,78 @@ describe("cron method validation", () => { expectResponseError(respond, { messageIncludes: "delivery.channel is required" }); }); + it("ignores stale ownerless channel config when validating default announce delivery", async () => { + setRuntimeConfig({ + session: { mainKey: "main" }, + channels: { + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + clickclack: { + token: "stale-token", + }, + }, + plugins: pluginEntries("slack"), + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + name: "ownerless config is not ambiguous", + delivery: { mode: "announce" }, + }), + ); + + expect(context.cron.add).toHaveBeenCalled(); + expectCronSuccess(respond); + }); + + it("rejects explicit announce delivery to stale ownerless channel config", async () => { + setRuntimeConfig({ + channels: { + slack: { + botToken: "xoxb-slack-token", + appToken: "xapp-slack-token", + }, + clickclack: { + token: "stale-token", + }, + }, + plugins: pluginEntries("slack"), + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + name: "ownerless channel is not deliverable", + delivery: { mode: "announce", channel: "clickclack" }, + }), + ); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { messageIncludes: "delivery.channel must be one of: slack" }); + }); + + it("rejects explicit announce delivery when only stale ownerless channel config exists", async () => { + setRuntimeConfig({ + channels: { + clickclack: { + token: "stale-token", + }, + }, + plugins: pluginEntries(), + } as OpenClawConfig); + + const { context, respond } = await invokeCronAdd( + agentTurnCronParams({ + name: "only ownerless channel is not deliverable", + delivery: { mode: "announce", channel: "clickclack" }, + }), + ); + + expect(context.cron.add).not.toHaveBeenCalled(); + expectResponseError(respond, { messageIncludes: "delivery.channel is not configured" }); + }); + it("accepts provider-prefixed announce target without delivery.channel when multiple channels are configured", async () => { setRuntimeConfig(telegramSlackConfig({ includeMainSession: true })); diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index a7b2a9d8933e..429b174415d6 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -3420,6 +3420,185 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["demo-other-channel"]); }); + it("announces explicit configured channels without installed owners", () => { + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + channels: { + clickclack: { + token: "configured", + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual(["clickclack"]); + }); + + it("does not announce ownerless explicit channels suppressed by plugin policy", () => { + const ownerlessChannelConfig = { + channels: { + clickclack: { + token: "configured", + }, + }, + } as OpenClawConfig; + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...ownerlessChannelConfig, + plugins: { + enabled: false, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...ownerlessChannelConfig, + plugins: { + deny: ["clickclack"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...ownerlessChannelConfig, + plugins: { + entries: { + clickclack: { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...ownerlessChannelConfig, + plugins: { + allow: ["slack"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + }); + + it("does not announce explicit channels suppressed by plugin policy", () => { + const baseConfig = { + channels: { + "demo-channel": { + token: "configured", + }, + }, + } as OpenClawConfig; + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...baseConfig, + plugins: { + enabled: false, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...baseConfig, + plugins: { + deny: ["demo-channel"], + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + ...baseConfig, + plugins: { + entries: { + "demo-channel": { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + }), + ).toStrictEqual([]); + }); + + it("keeps announce channels with another effective owner", () => { + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + channels: { + shared: { + token: "configured", + }, + }, + plugins: { + entries: { + "shared-good": { + enabled: true, + }, + "shared-disabled": { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: {}, + manifestRecords: [ + { + id: "shared-good", + channels: ["shared"], + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + } as never, + { + id: "shared-disabled", + channels: ["shared"], + origin: "config", + enabledByDefault: undefined, + providers: [], + cliBackends: [], + } as never, + ], + }), + ).toStrictEqual(["shared"]); + }); + it("does not treat activation-only declarations as channel ownership", () => { listPotentialConfiguredChannelIds.mockReturnValue(["activation-only-channel"]); listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index 2a22ad6989d5..acc2132ac43a 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -56,6 +56,12 @@ export type ConfiguredChannelPresencePolicyEntry = { blockedReasons: ConfiguredChannelBlockedReason[]; }; +const ANNOUNCE_SUPPRESSING_BLOCKED_REASONS = new Set([ + "plugins-disabled", + "blocked-by-denylist", + "plugin-disabled", +]); + function normalizeChannelIds(channelIds: Iterable): string[] { return sortUniqueStrings( [...channelIds].flatMap((channelId) => { @@ -437,18 +443,41 @@ export function listConfiguredAnnounceChannelIdsForConfig(params: { activationSourceConfig?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + manifestRecords?: readonly PluginManifestRecord[]; }): string[] { const disabledChannelIds = new Set(listExplicitlyDisabledChannelIdsForConfig(params.config)); + const trustConfig = params.activationSourceConfig ?? params.config; + const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); + const policy = resolveConfiguredChannelPresencePolicy({ + config: params.config, + activationSourceConfig: trustConfig, + workspaceDir: params.workspaceDir, + env: params.env, + includePersistedAuthState: false, + manifestRecords: params.manifestRecords, + }); + const policyDisabledChannelIds = new Set( + policy + .filter( + (entry) => + !entry.effective && + entry.blockedReasons.some((reason) => ANNOUNCE_SUPPRESSING_BLOCKED_REASONS.has(reason)), + ) + .map((entry) => entry.channelId), + ); + const explicitChannelIds = listExplicitConfiguredChannelIdsForConfig(params.config).filter( + (channelId) => + normalizedConfig.enabled && + !normalizedConfig.deny.includes(channelId) && + normalizedConfig.entries[channelId]?.enabled !== false && + (normalizedConfig.allow.length === 0 || normalizedConfig.allow.includes(channelId)), + ); return normalizeChannelIds([ - ...listExplicitConfiguredChannelIdsForConfig(params.config), - ...listConfiguredChannelIdsForReadOnlyScope({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir: params.workspaceDir, - env: params.env, - includePersistedAuthState: false, - }), - ]).filter((channelId) => !disabledChannelIds.has(channelId)); + ...explicitChannelIds, + ...policy.filter((entry) => entry.effective).map((entry) => entry.channelId), + ]).filter( + (channelId) => !disabledChannelIds.has(channelId) && !policyDisabledChannelIds.has(channelId), + ); } function resolveScopedChannelOwnerPluginIds(params: {