mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(channels): keep ownerless config visible but undeliverable
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 <plugin-dir>");
|
||||
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 });
|
||||
},
|
||||
};
|
||||
`,
|
||||
);
|
||||
@@ -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<string>(),
|
||||
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
|
||||
}));
|
||||
@@ -41,8 +41,8 @@ vi.mock("../config/config.js", () => ({
|
||||
vi.mock("../plugins/channel-plugin-ids.js", () => ({
|
||||
listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record<string, unknown> }) =>
|
||||
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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string[]> {
|
||||
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,
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -56,6 +56,12 @@ export type ConfiguredChannelPresencePolicyEntry = {
|
||||
blockedReasons: ConfiguredChannelBlockedReason[];
|
||||
};
|
||||
|
||||
const ANNOUNCE_SUPPRESSING_BLOCKED_REASONS = new Set<ConfiguredChannelBlockedReason>([
|
||||
"plugins-disabled",
|
||||
"blocked-by-denylist",
|
||||
"plugin-disabled",
|
||||
]);
|
||||
|
||||
function normalizeChannelIds(channelIds: Iterable<string>): 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: {
|
||||
|
||||
Reference in New Issue
Block a user