From 2580b81bd217702c9302072e6a70de9b90f64b9b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 22:52:56 -0700 Subject: [PATCH] refactor: move channel capability diagnostics into plugins --- docs/refactor/plugin-sdk.md | 2 + extensions/discord/src/channel.ts | 104 +++++ extensions/msteams/src/channel.ts | 45 +++ extensions/signal/src/channel.ts | 5 + extensions/slack/src/channel.ts | 58 +++ extensions/telegram/src/channel.ts | 27 +- src/auto-reply/reply/route-reply.test.ts | 7 +- src/auto-reply/reply/route-reply.ts | 29 +- .../plugins/message-actions.security.test.ts | 2 + src/channels/plugins/message-actions.ts | 18 +- src/channels/plugins/types.adapters.ts | 29 ++ src/channels/plugins/types.core.ts | 2 + src/channels/plugins/types.ts | 3 + src/channels/reply-prefix.ts | 7 +- src/commands/channels/add.ts | 16 - src/commands/channels/capabilities.test.ts | 36 +- src/commands/channels/capabilities.ts | 360 +++--------------- src/commands/channels/remove.ts | 5 - 18 files changed, 363 insertions(+), 392 deletions(-) diff --git a/docs/refactor/plugin-sdk.md b/docs/refactor/plugin-sdk.md index 05d519a0d24..5a630982a97 100644 --- a/docs/refactor/plugin-sdk.md +++ b/docs/refactor/plugin-sdk.md @@ -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 diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 26a69cf79e0..1b0e003202c 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -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 = { 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 = { + 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: 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, diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a21aa451eb8..c4d3f41054c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 = { + "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 = { }), 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, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 8b2f0998ff9..010df26d390 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -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 = { 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, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 04c9706bd95..f658b93d2c3 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -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>; +}) { + 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 = { 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: "", @@ -429,6 +458,35 @@ export const slackPlugin: ChannelPlugin = { } 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 = {}; + 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 = diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 1f0d94057a2..2aebfe5652c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -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 { + const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js"); await deleteTelegramUpdateOffset({ accountId }); }, }, @@ -515,6 +516,30 @@ export const telegramPlugin: ChannelPlugin { + 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 ?? diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 0a717f9bfc7..c0023ae1c37 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -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", }, { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 15036d0878f..8dc7499526a 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -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 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) { diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index 1dbd19de3e0..b8b62afdecd 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -25,6 +25,8 @@ const discordPlugin: ChannelPlugin = { actions: { listActions: () => ["kick"], supportsAction: ({ action }) => action === "kick", + requiresTrustedRequesterSender: ({ action, toolContext }) => + Boolean(action === "kick" && toolContext), handleAction, }, }; diff --git a/src/channels/plugins/message-actions.ts b/src/channels/plugins/message-actions.ts index 53bc14cfc10..506f2204493 100644 --- a/src/channels/plugins/message-actions.ts +++ b/src/channels/plugins/message-actions.ts @@ -6,23 +6,13 @@ import type { ChannelMessageActionContext, ChannelMessageActionName } from "./ty type ChannelActions = NonNullable>["actions"]>; -const trustedRequesterRequiredByChannel: Readonly< - Partial>> -> = { - discord: new Set(["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, + }), ); } diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index c8255f07542..084fa653bb8 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -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; +}; + +type BivariantCallback unknown> = { + bivarianceHack: T; +}["bivarianceHack"]; + export type ChannelSetupAdapter = { resolveAccountId?: (params: { cfg: OpenClawConfig; @@ -153,12 +169,25 @@ export type ChannelStatusAdapter Promise; + formatCapabilitiesProbe?: BivariantCallback< + (params: { probe: Probe }) => ChannelCapabilitiesDisplayLine[] + >; auditAccount?: (params: { account: ResolvedAccount; timeoutMs: number; cfg: OpenClawConfig; probe?: Probe; }) => Promise; + buildCapabilitiesDiagnostics?: BivariantCallback< + (params: { + account: ResolvedAccount; + timeoutMs: number; + cfg: OpenClawConfig; + probe?: Probe; + audit?: Audit; + target?: string; + }) => Promise + >; buildAccountSnapshot?: (params: { account: ResolvedAccount; cfg: OpenClawConfig; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 22b2c9387e7..4d94afe49fd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index a2abcc12dea..ffa098f0673 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -9,6 +9,9 @@ export type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelAuthAdapter, ChannelCommandAdapter, + ChannelCapabilitiesDiagnostics, + ChannelCapabilitiesDisplayLine, + ChannelCapabilitiesDisplayTone, ChannelConfigAdapter, ChannelDirectoryAdapter, ChannelExecApprovalAdapter, diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index c76b6175157..cfda423eeb9 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -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, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 0079e7ea881..4f8b3e8133c 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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}".`); diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index 5e838cc4ec8..3a70bdb85f9 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -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); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 30f64da43d9..acd28137b30 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -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 = { - "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; - - 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 } | 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: 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 = []; - 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(""); } diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index b7d012d0fac..1cd5fded7d3 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -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.`);