refactor: move channel capability diagnostics into plugins

This commit is contained in:
Peter Steinberger
2026-03-15 22:52:56 -07:00
parent f9e185887f
commit 2580b81bd2
18 changed files with 363 additions and 392 deletions

View File

@@ -223,6 +223,8 @@ channel-specific UX and routing behavior:
- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles
(for example Slack interactive replies)
- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing
- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned
`/channels capabilities` probe display and extra audits/scopes
- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading
- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping
- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates

View File

@@ -38,8 +38,11 @@ import {
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import type { DiscordProbe } from "./probe.js";
import { getDiscordRuntime } from "./runtime.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
type DiscordSendFn = ReturnType<
@@ -47,11 +50,27 @@ type DiscordSendFn = ReturnType<
>["channel"]["discord"]["sendMessageDiscord"];
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
presence?: string;
}) {
if (!intents) {
return "unknown";
}
return [
`messageContent=${intents.messageContent ?? "unknown"}`,
`guildMembers=${intents.guildMembers ?? "unknown"}`,
`presence=${intents.presence ?? "unknown"}`,
].join(" ");
}
const discordMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
@@ -355,6 +374,91 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
formatCapabilitiesProbe: ({ probe }) => {
const discordProbe = probe as DiscordProbe | undefined;
const lines = [];
if (discordProbe?.bot?.username) {
const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : "";
lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` });
}
if (discordProbe?.application?.intents) {
lines.push({ text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}` });
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, timeoutMs, target }) => {
if (!target?.trim()) {
return undefined;
}
const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" });
const details: Record<string, unknown> = {
target: {
raw: target,
normalized: parsedTarget?.normalized,
kind: parsedTarget?.kind,
channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined,
},
};
if (!parsedTarget || parsedTarget.kind !== "channel") {
return {
details,
lines: [
{
text: "Permissions: Target looks like a DM user; pass channel:<id> to audit channel permissions.",
tone: "error",
},
],
};
}
const token = account.token?.trim();
if (!token) {
return {
details,
lines: [
{
text: "Permissions: Discord bot token missing for permission audit.",
tone: "error",
},
],
};
}
try {
const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, {
token,
accountId: account.accountId ?? undefined,
});
const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter(
(permission) => !perms.permissions.includes(permission),
);
details.permissions = {
channelId: perms.channelId,
guildId: perms.guildId,
isDm: perms.isDm,
channelType: perms.channelType,
permissions: perms.permissions,
missingRequired,
raw: perms.raw,
};
return {
details,
lines: [
{
text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`,
},
missingRequired.length > 0
? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" }
: { text: "Missing required: none", tone: "success" },
],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
details.permissions = { channelId: parsedTarget.id, error: message };
return {
details,
lines: [{ text: `Permissions: ${message}`, tone: "error" }],
};
}
},
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,

View File

@@ -17,6 +17,7 @@ import {
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/msteams";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
@@ -47,6 +48,16 @@ const meta = {
order: 60,
} as const;
const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
"ChannelMessage.Read.All": "channel history",
"Chat.Read.All": "chat history",
"Channel.ReadBasic.All": "channel list",
"Team.ReadBasic.All": "team list",
"TeamsActivity.Read.All": "teams activity",
"Sites.Read.All": "files (SharePoint)",
"Files.Read.All": "files (OneDrive)",
};
async function loadMSTeamsChannelRuntime() {
return await import("./channel.runtime.js");
}
@@ -435,6 +446,40 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}),
probeAccount: async ({ cfg }) =>
await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams),
formatCapabilitiesProbe: ({ probe }) => {
const teamsProbe = probe as ProbeMSTeamsResult | undefined;
const lines: Array<{ text: string; tone?: "error" }> = [];
const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : "";
if (appId) {
lines.push({ text: `App: ${appId}` });
}
const graph = teamsProbe?.graph;
if (graph) {
const roles = Array.isArray(graph.roles)
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
: [];
const scopes = Array.isArray(graph.scopes)
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
: [];
const formatPermission = (permission: string) => {
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
return hint ? `${permission} (${hint})` : permission;
};
if (graph.ok === false) {
lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" });
} else if (roles.length > 0 || scopes.length > 0) {
if (roles.length > 0) {
lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` });
}
if (scopes.length > 0) {
lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` });
}
} else if (graph.ok === true) {
lines.push({ text: "Graph: ok" });
}
}
return lines;
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,

View File

@@ -27,6 +27,7 @@ import {
type ResolvedSignalAccount,
} from "openclaw/plugin-sdk/signal";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import type { SignalProbe } from "./probe.js";
import { getSignalRuntime } from "./runtime.js";
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
@@ -220,6 +221,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
const baseUrl = account.baseUrl;
return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) =>
(probe as SignalProbe | undefined)?.version
? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }]
: [],
buildAccountSnapshot: ({ account, runtime, probe }) => ({
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
baseUrl: account.baseUrl,

View File

@@ -36,7 +36,10 @@ import {
} from "openclaw/plugin-sdk/slack";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import type { SlackProbe } from "./probe.js";
import { getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
import { parseSlackTarget } from "./targets.js";
@@ -126,6 +129,21 @@ function resolveSlackAutoThreadId(params: {
return context.currentThreadTs;
}
function formatSlackScopeDiagnostic(params: {
tokenType: "bot" | "user";
result: Awaited<ReturnType<typeof fetchSlackScopes>>;
}) {
const source = params.result.source ? ` (${params.result.source})` : "";
const label = params.tokenType === "user" ? "User scopes" : "Bot scopes";
if (params.result.ok && params.result.scopes?.length) {
return { text: `${label}${source}: ${params.result.scopes.join(", ")}` } as const;
}
return {
text: `${label}: ${params.result.error ?? "scope lookup failed"}`,
tone: "error",
} as const;
}
const slackConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
@@ -285,6 +303,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
normalizeTarget: normalizeSlackMessagingTarget,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
hasStructuredReplyPayload: ({ payload }) => {
const slackData = payload.channelData?.slack;
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
return false;
}
try {
return Boolean(parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks)?.length);
} catch {
return false;
}
},
targetResolver: {
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",
@@ -429,6 +458,35 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}
return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) => {
const slackProbe = probe as SlackProbe | undefined;
const lines = [];
if (slackProbe?.bot?.name) {
lines.push({ text: `Bot: @${slackProbe.bot.name}` });
}
if (slackProbe?.team?.name || slackProbe?.team?.id) {
const id = slackProbe.team?.id ? ` (${slackProbe.team.id})` : "";
lines.push({ text: `Team: ${slackProbe.team?.name ?? "unknown"}${id}` });
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, timeoutMs }) => {
const lines = [];
const details: Record<string, unknown> = {};
const botToken = account.botToken?.trim();
const userToken = account.config.userToken?.trim();
const botScopes = botToken
? await fetchSlackScopes(botToken, timeoutMs)
: { ok: false, error: "Slack bot token missing." };
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
details.botScopes = botScopes;
if (userToken) {
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
details.userScopes = userScopes;
}
return { lines, details };
},
buildAccountSnapshot: ({ account, runtime, probe }) => {
const mode = account.config.mode ?? "socket";
const configured =

View File

@@ -54,7 +54,6 @@ import { sendTypingTelegram } from "./send.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
import { parseTelegramTarget } from "./targets.js";
import { deleteTelegramUpdateOffset } from "./update-offset-store.js";
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
@@ -334,10 +333,12 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim();
const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim();
if (previousToken !== nextToken) {
const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId });
}
},
onAccountRemoved: async ({ accountId }) => {
const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId });
},
},
@@ -515,6 +516,30 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
proxyUrl: account.config.proxy,
network: account.config.network,
}),
formatCapabilitiesProbe: ({ probe }) => {
const lines = [];
if (probe?.bot?.username) {
const botId = probe.bot.id ? ` (${probe.bot.id})` : "";
lines.push({ text: `Bot: @${probe.bot.username}${botId}` });
}
const flags: string[] = [];
if (typeof probe?.bot?.canJoinGroups === "boolean") {
flags.push(`joinGroups=${probe.bot.canJoinGroups}`);
}
if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") {
flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`);
}
if (typeof probe?.bot?.supportsInlineQueries === "boolean") {
flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`);
}
if (flags.length > 0) {
lines.push({ text: `Flags: ${flags.join(" ")}` });
}
if (probe?.webhook?.url !== undefined) {
lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` });
}
return lines;
},
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mattermostPlugin } from "../../../extensions/mattermost/src/channel.js";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { discordOutbound } from "../../channels/plugins/outbound/discord.js";
import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
@@ -540,7 +541,11 @@ const defaultRegistry = createTestRegistry([
},
{
pluginId: "slack",
plugin: createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }),
plugin: {
...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }),
messaging: slackPlugin.messaging,
threading: slackPlugin.threading,
},
source: "test",
},
{

View File

@@ -7,8 +7,6 @@
* across multiple providers.
*/
import { parseSlackBlocksInput } from "../../../extensions/slack/src/blocks-input.js";
import { isSlackInteractiveRepliesEnabled } from "../../../extensions/slack/src/interactive-replies.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
@@ -101,9 +99,10 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
enableSlackInteractiveReplies:
plugin?.messaging?.enableInteractiveReplies?.({ cfg, accountId }) ??
(channel === "slack" ? isSlackInteractiveRepliesEnabled({ cfg, accountId }) : false),
enableSlackInteractiveReplies: plugin?.messaging?.enableInteractiveReplies?.({
cfg,
accountId,
}),
});
if (!normalized) {
return { ok: true };
@@ -121,23 +120,9 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
: [];
const replyToId = externalPayload.replyToId;
const hasInteractive = (externalPayload.interactive?.blocks.length ?? 0) > 0;
let hasChannelData =
externalPayload.channelData != null && Object.keys(externalPayload.channelData).length > 0;
if (
channel === "slack" &&
externalPayload.channelData?.slack &&
typeof externalPayload.channelData.slack === "object" &&
!Array.isArray(externalPayload.channelData.slack)
) {
try {
hasChannelData = Boolean(
parseSlackBlocksInput((externalPayload.channelData.slack as { blocks?: unknown }).blocks)
?.length,
);
} catch {
hasChannelData = false;
}
}
const hasChannelData = plugin?.messaging?.hasStructuredReplyPayload?.({
payload: externalPayload,
});
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0 && !hasInteractive && !hasChannelData) {

View File

@@ -25,6 +25,8 @@ const discordPlugin: ChannelPlugin = {
actions: {
listActions: () => ["kick"],
supportsAction: ({ action }) => action === "kick",
requiresTrustedRequesterSender: ({ action, toolContext }) =>
Boolean(action === "kick" && toolContext),
handleAction,
},
};

View File

@@ -6,23 +6,13 @@ import type { ChannelMessageActionContext, ChannelMessageActionName } from "./ty
type ChannelActions = NonNullable<NonNullable<ReturnType<typeof getChannelPlugin>>["actions"]>;
const trustedRequesterRequiredByChannel: Readonly<
Partial<Record<string, ReadonlySet<ChannelMessageActionName>>>
> = {
discord: new Set<ChannelMessageActionName>(["timeout", "kick", "ban"]),
};
function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean {
const plugin = getChannelPlugin(ctx.channel);
const fromPlugin = plugin?.actions?.requiresTrustedRequesterSender?.({
action: ctx.action,
toolContext: ctx.toolContext,
});
if (fromPlugin != null) {
return fromPlugin;
}
return Boolean(
trustedRequesterRequiredByChannel[ctx.channel]?.has(ctx.action) && ctx.toolContext,
plugin?.actions?.requiresTrustedRequesterSender?.({
action: ctx.action,
toolContext: ctx.toolContext,
}),
);
}

View File

@@ -35,6 +35,22 @@ export type ChannelExecApprovalForwardTarget = {
source?: "session" | "target";
};
export type ChannelCapabilitiesDisplayTone = "default" | "muted" | "success" | "warn" | "error";
export type ChannelCapabilitiesDisplayLine = {
text: string;
tone?: ChannelCapabilitiesDisplayTone;
};
export type ChannelCapabilitiesDiagnostics = {
lines?: ChannelCapabilitiesDisplayLine[];
details?: Record<string, unknown>;
};
type BivariantCallback<T extends (...args: never[]) => unknown> = {
bivarianceHack: T;
}["bivarianceHack"];
export type ChannelSetupAdapter = {
resolveAccountId?: (params: {
cfg: OpenClawConfig;
@@ -153,12 +169,25 @@ export type ChannelStatusAdapter<ResolvedAccount, Probe = unknown, Audit = unkno
timeoutMs: number;
cfg: OpenClawConfig;
}) => Promise<Probe>;
formatCapabilitiesProbe?: BivariantCallback<
(params: { probe: Probe }) => ChannelCapabilitiesDisplayLine[]
>;
auditAccount?: (params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: OpenClawConfig;
probe?: Probe;
}) => Promise<Audit>;
buildCapabilitiesDiagnostics?: BivariantCallback<
(params: {
account: ResolvedAccount;
timeoutMs: number;
cfg: OpenClawConfig;
probe?: Probe;
audit?: Audit;
target?: string;
}) => Promise<ChannelCapabilitiesDiagnostics | undefined>
>;
buildAccountSnapshot?: (params: {
account: ResolvedAccount;
cfg: OpenClawConfig;

View File

@@ -2,6 +2,7 @@ import type { TopLevelComponents } from "@buape/carbon";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { MsgContext } from "../../auto-reply/templating.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PollInput } from "../../polls.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
@@ -349,6 +350,7 @@ export type ChannelMessagingAdapter = {
cfg: OpenClawConfig;
accountId?: string | null;
}) => boolean;
hasStructuredReplyPayload?: (params: { payload: ReplyPayload }) => boolean;
targetResolver?: {
looksLikeId?: (raw: string, normalized?: string) => boolean;
hint?: string;

View File

@@ -9,6 +9,9 @@ export type { ChannelMessageCapability } from "./message-capabilities.js";
export type {
ChannelAuthAdapter,
ChannelCommandAdapter,
ChannelCapabilitiesDiagnostics,
ChannelCapabilitiesDisplayLine,
ChannelCapabilitiesDisplayTone,
ChannelConfigAdapter,
ChannelDirectoryAdapter,
ChannelExecApprovalAdapter,

View File

@@ -1,4 +1,3 @@
import { isSlackInteractiveRepliesEnabled } from "../../extensions/slack/src/interactive-replies.js";
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
import {
extractShortModelName,
@@ -55,11 +54,7 @@ export function createReplyPrefixContext(params: {
? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({
cfg,
accountId: params.accountId,
}) ??
(params.channel === "slack"
? isSlackInteractiveRepliesEnabled({ cfg, accountId: params.accountId })
: undefined) ??
undefined)
}) ?? undefined)
: undefined,
responsePrefixContextProvider: () => prefixContext,
onModelSelected,

View File

@@ -321,12 +321,6 @@ export async function channelsAddCommand(
});
}
let previousTelegramToken = "";
if (channel === "telegram") {
const { resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js");
previousTelegramToken = resolveTelegramAccount({ cfg: prevConfig, accountId }).token.trim();
}
nextConfig = applyChannelAccountConfig({
cfg: nextConfig,
channel,
@@ -340,16 +334,6 @@ export async function channelsAddCommand(
accountId,
runtime,
});
if (channel === "telegram") {
const [{ resolveTelegramAccount }, { deleteTelegramUpdateOffset }] = await Promise.all([
import("../../../extensions/telegram/src/accounts.js"),
import("../../../extensions/telegram/src/update-offset-store.js"),
]);
const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
if (previousTelegramToken !== nextTelegramToken) {
await deleteTelegramUpdateOffset({ accountId });
}
}
await writeConfigFile(nextConfig);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);

View File

@@ -1,7 +1,6 @@
process.env.NO_COLOR = "1";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchSlackScopes } from "../../../extensions/slack/src/scopes.js";
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { channelsCapabilitiesCommand } from "./capabilities.js";
@@ -21,10 +20,6 @@ vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: vi.fn(),
}));
vi.mock("../../../extensions/slack/src/scopes.js", () => ({
fetchSlackScopes: vi.fn(),
}));
const runtime = {
log: (...args: unknown[]) => {
logs.push(args.map(String).join(" "));
@@ -95,14 +90,22 @@ describe("channelsCapabilitiesCommand", () => {
},
probe: { ok: true, bot: { name: "openclaw" }, team: { name: "team" } },
});
plugin.status = {
...plugin.status,
formatCapabilitiesProbe: () => [{ text: "Bot: @openclaw" }, { text: "Team: team" }],
buildCapabilitiesDiagnostics: async () => ({
lines: [
{ text: "Bot scopes (auth.scopes): chat:write" },
{ text: "User scopes (auth.scopes): users:read" },
],
details: {
botScopes: { ok: true, scopes: ["chat:write"], source: "auth.scopes" },
userScopes: { ok: true, scopes: ["users:read"], source: "auth.scopes" },
},
}),
};
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
vi.mocked(getChannelPlugin).mockReturnValue(plugin);
vi.mocked(fetchSlackScopes).mockImplementation(async (token: string) => {
if (token === "xoxp-user") {
return { ok: true, scopes: ["users:read"], source: "auth.scopes" };
}
return { ok: true, scopes: ["chat:write"], source: "auth.scopes" };
});
await channelsCapabilitiesCommand({ channel: "slack" }, runtime);
@@ -111,8 +114,6 @@ describe("channelsCapabilitiesCommand", () => {
expect(output).toContain("User scopes");
expect(output).toContain("chat:write");
expect(output).toContain("users:read");
expect(fetchSlackScopes).toHaveBeenCalledWith("xoxb-bot", expect.any(Number));
expect(fetchSlackScopes).toHaveBeenCalledWith("xoxp-user", expect.any(Number));
});
it("prints Teams Graph permission hints when present", async () => {
@@ -127,6 +128,15 @@ describe("channelsCapabilitiesCommand", () => {
},
},
});
plugin.status = {
...plugin.status,
formatCapabilitiesProbe: () => [
{ text: "App: app-id" },
{
text: "Graph roles: ChannelMessage.Read.All (channel history), Files.Read.All (files (OneDrive))",
},
],
};
vi.mocked(listChannelPlugins).mockReturnValue([plugin]);
vi.mocked(getChannelPlugin).mockReturnValue(plugin);

View File

@@ -1,9 +1,11 @@
import { fetchChannelPermissionsDiscord } from "../../../extensions/discord/src/send.js";
import { parseDiscordTarget } from "../../../extensions/discord/src/targets.js";
import { fetchSlackScopes, type SlackScopesResult } from "../../../extensions/slack/src/scopes.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js";
import type {
ChannelCapabilities,
ChannelCapabilitiesDiagnostics,
ChannelCapabilitiesDisplayLine,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { danger } from "../../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
@@ -18,24 +20,6 @@ export type ChannelsCapabilitiesOptions = {
json?: boolean;
};
type DiscordTargetSummary = {
raw?: string;
normalized?: string;
kind?: "channel" | "user";
channelId?: string;
};
type DiscordPermissionsReport = {
channelId?: string;
guildId?: string;
isDm?: boolean;
channelType?: number;
permissions?: string[];
missingRequired?: string[];
raw?: string;
error?: string;
};
type ChannelCapabilitiesReport = {
channel: string;
accountId: string;
@@ -45,24 +29,7 @@ type ChannelCapabilitiesReport = {
support?: ChannelCapabilities;
actions?: string[];
probe?: unknown;
slackScopes?: Array<{
tokenType: "bot" | "user";
result: SlackScopesResult;
}>;
target?: DiscordTargetSummary;
channelPermissions?: DiscordPermissionsReport;
};
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
"ChannelMessage.Read.All": "channel history",
"Chat.Read.All": "chat history",
"Channel.ReadBasic.All": "channel list",
"Team.ReadBasic.All": "team list",
"TeamsActivity.Read.All": "teams activity",
"Sites.Read.All": "files (SharePoint)",
"Files.Read.All": "files (OneDrive)",
diagnostics?: ChannelCapabilitiesDiagnostics;
};
function normalizeTimeout(raw: unknown, fallback = 10_000) {
@@ -117,221 +84,35 @@ function formatSupport(capabilities?: ChannelCapabilities) {
return bits.length ? bits.join(" ") : "none";
}
function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined {
if (!raw) {
return undefined;
}
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
if (!target) {
return { raw };
}
if (target.kind === "channel") {
return {
raw,
normalized: target.normalized,
kind: "channel",
channelId: target.id,
};
}
if (target.kind === "user") {
return {
raw,
normalized: target.normalized,
kind: "user",
};
}
return { raw, normalized: target.normalized };
}
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
presence?: string;
}) {
if (!intents) {
return "unknown";
}
return [
`messageContent=${intents.messageContent ?? "unknown"}`,
`guildMembers=${intents.guildMembers ?? "unknown"}`,
`presence=${intents.presence ?? "unknown"}`,
].join(" ");
}
function formatProbeLines(channelId: string, probe: unknown): string[] {
const lines: string[] = [];
function formatGenericProbeLines(probe: unknown): ChannelCapabilitiesDisplayLine[] {
if (!probe || typeof probe !== "object") {
return lines;
return [];
}
const probeObj = probe as Record<string, unknown>;
if (channelId === "discord") {
const bot = probeObj.bot as { id?: string | null; username?: string | null } | undefined;
if (bot?.username) {
const botId = bot.id ? ` (${bot.id})` : "";
lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`);
}
const app = probeObj.application as { intents?: Record<string, unknown> } | undefined;
if (app?.intents) {
lines.push(`Intents: ${formatDiscordIntents(app.intents)}`);
}
}
if (channelId === "telegram") {
const bot = probeObj.bot as { username?: string | null; id?: number | null } | undefined;
if (bot?.username) {
const botId = bot.id ? ` (${bot.id})` : "";
lines.push(`Bot: ${theme.accent(`@${bot.username}`)}${botId}`);
}
const flags: string[] = [];
const canJoinGroups = (bot as { canJoinGroups?: boolean | null })?.canJoinGroups;
const canReadAll = (bot as { canReadAllGroupMessages?: boolean | null })
?.canReadAllGroupMessages;
const inlineQueries = (bot as { supportsInlineQueries?: boolean | null })
?.supportsInlineQueries;
if (typeof canJoinGroups === "boolean") {
flags.push(`joinGroups=${canJoinGroups}`);
}
if (typeof canReadAll === "boolean") {
flags.push(`readAllGroupMessages=${canReadAll}`);
}
if (typeof inlineQueries === "boolean") {
flags.push(`inlineQueries=${inlineQueries}`);
}
if (flags.length > 0) {
lines.push(`Flags: ${flags.join(" ")}`);
}
const webhook = probeObj.webhook as { url?: string | null } | undefined;
if (webhook?.url !== undefined) {
lines.push(`Webhook: ${webhook.url || "none"}`);
}
}
if (channelId === "slack") {
const bot = probeObj.bot as { name?: string } | undefined;
const team = probeObj.team as { name?: string; id?: string } | undefined;
if (bot?.name) {
lines.push(`Bot: ${theme.accent(`@${bot.name}`)}`);
}
if (team?.name || team?.id) {
const id = team?.id ? ` (${team.id})` : "";
lines.push(`Team: ${team?.name ?? "unknown"}${id}`);
}
}
if (channelId === "signal") {
const version = probeObj.version as string | null | undefined;
if (version) {
lines.push(`Signal daemon: ${version}`);
}
}
if (channelId === "msteams") {
const appId = typeof probeObj.appId === "string" ? probeObj.appId.trim() : "";
if (appId) {
lines.push(`App: ${theme.accent(appId)}`);
}
const graph = probeObj.graph as
| { ok?: boolean; roles?: unknown; scopes?: unknown; error?: string }
| undefined;
if (graph) {
const roles = Array.isArray(graph.roles)
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
: [];
const scopes =
typeof graph.scopes === "string"
? graph.scopes
.split(/\s+/)
.map((scope) => scope.trim())
.filter(Boolean)
: Array.isArray(graph.scopes)
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
: [];
if (graph.ok === false) {
lines.push(`Graph: ${theme.error(graph.error ?? "failed")}`);
} else if (roles.length > 0 || scopes.length > 0) {
const formatPermission = (permission: string) => {
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
return hint ? `${permission} (${hint})` : permission;
};
if (roles.length > 0) {
lines.push(`Graph roles: ${roles.map(formatPermission).join(", ")}`);
}
if (scopes.length > 0) {
lines.push(`Graph scopes: ${scopes.map(formatPermission).join(", ")}`);
}
} else if (graph.ok === true) {
lines.push("Graph: ok");
}
}
}
const ok = typeof probeObj.ok === "boolean" ? probeObj.ok : undefined;
if (ok === true && lines.length === 0) {
lines.push("Probe: ok");
if (ok === true) {
return [{ text: "Probe: ok" }];
}
if (ok === false) {
const error =
typeof probeObj.error === "string" && probeObj.error ? ` (${probeObj.error})` : "";
lines.push(`Probe: ${theme.error(`failed${error}`)}`);
return [{ text: `Probe: failed${error}`, tone: "error" }];
}
return lines;
return [];
}
async function buildDiscordPermissions(params: {
account: { token?: string; accountId?: string };
target?: string;
}): Promise<{ target?: DiscordTargetSummary; report?: DiscordPermissionsReport }> {
const target = summarizeDiscordTarget(params.target?.trim());
if (!target) {
return {};
}
if (target.kind !== "channel" || !target.channelId) {
return {
target,
report: {
error: "Target looks like a DM user; pass channel:<id> to audit channel permissions.",
},
};
}
const token = params.account.token?.trim();
if (!token) {
return {
target,
report: {
channelId: target.channelId,
error: "Discord bot token missing for permission audit.",
},
};
}
try {
const perms = await fetchChannelPermissionsDiscord(target.channelId, {
token,
accountId: params.account.accountId ?? undefined,
});
const missing = REQUIRED_DISCORD_PERMISSIONS.filter(
(permission) => !perms.permissions.includes(permission),
);
return {
target,
report: {
channelId: perms.channelId,
guildId: perms.guildId,
isDm: perms.isDm,
channelType: perms.channelType,
permissions: perms.permissions,
missingRequired: missing.length ? missing : [],
raw: perms.raw,
},
};
} catch (err) {
return {
target,
report: {
channelId: target.channelId,
error: err instanceof Error ? err.message : String(err),
},
};
function renderDisplayLine(line: ChannelCapabilitiesDisplayLine) {
switch (line.tone) {
case "muted":
return theme.muted(line.text);
case "success":
return theme.success(line.text);
case "warn":
return theme.warn(line.text);
case "error":
return theme.error(line.text);
default:
return line.text;
}
}
@@ -378,41 +159,16 @@ async function resolveChannelReports(params: {
}
}
let slackScopes: ChannelCapabilitiesReport["slackScopes"];
if (plugin.id === "slack" && configured && enabled) {
const botToken = (resolvedAccount as { botToken?: string }).botToken?.trim();
const userToken = (resolvedAccount as { userToken?: string }).userToken?.trim();
const scopeReports: NonNullable<ChannelCapabilitiesReport["slackScopes"]> = [];
if (botToken) {
scopeReports.push({
tokenType: "bot",
result: await fetchSlackScopes(botToken, timeoutMs),
});
} else {
scopeReports.push({
tokenType: "bot",
result: { ok: false, error: "Slack bot token missing." },
});
}
if (userToken) {
scopeReports.push({
tokenType: "user",
result: await fetchSlackScopes(userToken, timeoutMs),
});
}
slackScopes = scopeReports;
}
let discordTarget: DiscordTargetSummary | undefined;
let discordPermissions: DiscordPermissionsReport | undefined;
if (plugin.id === "discord" && params.target) {
const perms = await buildDiscordPermissions({
account: resolvedAccount as { token?: string; accountId?: string },
target: params.target,
});
discordTarget = perms.target;
discordPermissions = perms.report;
}
const diagnostics =
configured && enabled
? await plugin.status?.buildCapabilitiesDiagnostics?.({
account: resolvedAccount,
timeoutMs,
cfg,
probe,
target: params.target,
})
: undefined;
reports.push({
channel: plugin.id,
@@ -425,10 +181,8 @@ async function resolveChannelReports(params: {
enabled,
support: plugin.capabilities,
probe,
target: discordTarget,
channelPermissions: discordPermissions,
actions,
slackScopes,
diagnostics,
});
}
return reports;
@@ -451,8 +205,8 @@ export async function channelsCapabilitiesCommand(
runtime.exit(1);
return;
}
if (rawTarget && rawChannel !== "discord") {
runtime.error(danger("--target requires --channel discord."));
if (rawTarget && (!rawChannel || rawChannel === "all")) {
runtime.error(danger("--target requires a specific --channel."));
runtime.exit(1);
return;
}
@@ -484,7 +238,7 @@ export async function channelsCapabilitiesCommand(
cfg,
timeoutMs,
accountOverride,
target: rawTarget && plugin.id === "discord" ? rawTarget : undefined,
target: rawTarget || undefined,
})),
);
}
@@ -513,39 +267,17 @@ export async function channelsCapabilitiesCommand(
const enabledLabel = report.enabled === false ? "disabled" : "enabled";
lines.push(`Status: ${configuredLabel}, ${enabledLabel}`);
}
const probeLines = formatProbeLines(report.channel, report.probe);
const probeLines =
getChannelPlugin(report.channel)?.status?.formatCapabilitiesProbe?.({
probe: report.probe,
}) ?? formatGenericProbeLines(report.probe);
if (probeLines.length > 0) {
lines.push(...probeLines);
lines.push(...probeLines.map(renderDisplayLine));
} else if (report.configured && report.enabled) {
lines.push(theme.muted("Probe: unavailable"));
}
if (report.channel === "slack" && report.slackScopes) {
for (const entry of report.slackScopes) {
const source = entry.result.source ? ` (${entry.result.source})` : "";
const label = entry.tokenType === "user" ? "User scopes" : "Bot scopes";
if (entry.result.ok && entry.result.scopes?.length) {
lines.push(`${label}${source}: ${entry.result.scopes.join(", ")}`);
} else if (entry.result.error) {
lines.push(`${label}: ${theme.error(entry.result.error)}`);
}
}
}
if (report.channel === "discord" && report.channelPermissions) {
const perms = report.channelPermissions;
if (perms.error) {
lines.push(`Permissions: ${theme.error(perms.error)}`);
} else {
const list = perms.permissions?.length ? perms.permissions.join(", ") : "none";
const label = perms.channelId ? ` (${perms.channelId})` : "";
lines.push(`Permissions${label}: ${list}`);
if (perms.missingRequired && perms.missingRequired.length > 0) {
lines.push(`${theme.warn("Missing required:")} ${perms.missingRequired.join(", ")}`);
} else {
lines.push(theme.success("Missing required: none"));
}
}
} else if (report.channel === "discord" && rawTarget && !report.channelPermissions) {
lines.push(theme.muted("Permissions: skipped (no target)."));
if (report.diagnostics?.lines?.length) {
lines.push(...report.diagnostics.lines.map(renderDisplayLine));
}
lines.push("");
}

View File

@@ -118,11 +118,6 @@ export async function channelsRemoveCommand(
accountId: resolvedAccountId,
runtime,
});
if (channel === "telegram") {
const { deleteTelegramUpdateOffset } =
await import("../../../extensions/telegram/src/update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId: resolvedAccountId });
}
} else {
if (!plugin.config.setAccountEnabled) {
runtime.error(`Channel ${channel} does not support disable.`);