diff --git a/CHANGELOG.md b/CHANGELOG.md index d54c4e616..03c035c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x. - macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg. -- CLI: add `channels capabilities` to summarize channel support and provider probes. +- CLI: add `channels capabilities` with provider probes (Discord intents, Slack scopes, Teams Graph). ### Fixes - Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index b6057b0dd..76e84895e 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -56,4 +56,4 @@ clawdbot channels capabilities --channel discord --target channel:123 Notes: - `--channel` is optional; omit it to list every channel (including extensions). - `--target` accepts `channel:` or a raw numeric channel id and only applies to Discord. -- This probes provider APIs where possible (Discord intents, Telegram webhook, Slack auth test, Signal daemon); channels without probes report `Probe: unavailable`. +- Probes are provider-specific: Discord intents + optional channel permissions; Slack token scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes when available. Channels without probes report `Probe: unavailable`. diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 17fad386e..f72a2da11 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -7,6 +7,7 @@ import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { msteamsOnboardingAdapter } from "./onboarding.js"; import { msteamsOutbound } from "./outbound.js"; +import { probeMSTeams } from "./probe.js"; import { sendMessageMSTeams } from "./send.js"; import { resolveMSTeamsCredentials } from "./token.js"; @@ -218,7 +219,8 @@ export const msteamsPlugin: ChannelPlugin = { probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), - buildAccountSnapshot: ({ account, runtime }) => ({ + probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, @@ -227,6 +229,7 @@ export const msteamsPlugin: ChannelPlugin = { lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, port: runtime?.port ?? null, + probe, }), }, gateway: { diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts index bb8dcb942..502f2d114 100644 --- a/extensions/msteams/src/probe.ts +++ b/extensions/msteams/src/probe.ts @@ -7,8 +7,52 @@ export type ProbeMSTeamsResult = { ok: boolean; error?: string; appId?: string; + graph?: { + ok: boolean; + error?: string; + roles?: string[]; + scopes?: string[]; + }; }; +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? + (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length < 2) return null; + const payload = parts[1] ?? ""; + const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="); + const normalized = padded.replace(/-/g, "+").replace(/_/g, "/"); + try { + const decoded = Buffer.from(normalized, "base64").toString("utf8"); + const parsed = JSON.parse(decoded) as Record; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.map((entry) => String(entry).trim()).filter(Boolean); + return out.length > 0 ? out : undefined; +} + +function readScopes(value: unknown): string[] | undefined { + if (typeof value !== "string") return undefined; + const out = value.split(/\s+/).map((entry) => entry.trim()).filter(Boolean); + return out.length > 0 ? out : undefined; +} + export async function probeMSTeams(cfg?: MSTeamsConfig): Promise { const creds = resolveMSTeamsCredentials(cfg); if (!creds) { @@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise 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"}`); @@ -153,6 +164,35 @@ function formatProbeLines(channelId: string, probe: unknown): string[] { } } + 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) { + if (roles.length > 0) lines.push(`Graph roles: ${roles.join(", ")}`); + if (scopes.length > 0) lines.push(`Graph scopes: ${scopes.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"); @@ -236,6 +276,11 @@ async function resolveChannelReports(params: { : [resolveChannelDefaultAccountId({ plugin, cfg, accountIds: ids })]; })(); const reports: ChannelCapabilitiesReport[] = []; + const listedActions = plugin.actions?.listActions?.({ cfg }) ?? []; + const actions = Array.from( + new Set(["send", "broadcast", ...listedActions.map((action) => String(action))]), + ); + for (const accountId of accountIds) { const resolvedAccount = plugin.config.resolveAccount(cfg, accountId); const configured = plugin.config.isConfigured @@ -257,6 +302,16 @@ async function resolveChannelReports(params: { } } + let scopes: SlackScopesResult | undefined; + if (plugin.id === "slack" && configured && enabled) { + const token = (resolvedAccount as { botToken?: string }).botToken?.trim(); + if (!token) { + scopes = { ok: false, error: "Slack bot token missing." }; + } else { + scopes = await fetchSlackScopes(token, timeoutMs); + } + } + let discordTarget: DiscordTargetSummary | undefined; let discordPermissions: DiscordPermissionsReport | undefined; if (plugin.id === "discord" && params.target) { @@ -281,6 +336,8 @@ async function resolveChannelReports(params: { probe, target: discordTarget, channelPermissions: discordPermissions, + actions, + scopes, }); } return reports; @@ -354,6 +411,9 @@ export async function channelsCapabilitiesCommand( }); lines.push(theme.heading(label)); lines.push(`Support: ${formatSupport(report.support)}`); + if (report.actions && report.actions.length > 0) { + lines.push(`Actions: ${report.actions.join(", ")}`); + } if (report.configured === false || report.enabled === false) { const configuredLabel = report.configured === false ? "not configured" : "configured"; const enabledLabel = report.enabled === false ? "disabled" : "enabled"; @@ -365,6 +425,14 @@ export async function channelsCapabilitiesCommand( } else if (report.configured && report.enabled) { lines.push(theme.muted("Probe: unavailable")); } + if (report.channel === "slack" && report.scopes) { + if (report.scopes.ok && report.scopes.scopes?.length) { + const source = report.scopes.source ? ` (${report.scopes.source})` : ""; + lines.push(`Scopes${source}: ${report.scopes.scopes.join(", ")}`); + } else if (report.scopes.error) { + lines.push(`Scopes: ${theme.error(report.scopes.error)}`); + } + } if (report.channel === "discord" && report.channelPermissions) { const perms = report.channelPermissions; if (perms.error) { diff --git a/src/slack/scopes.ts b/src/slack/scopes.ts new file mode 100644 index 000000000..43d46e3ea --- /dev/null +++ b/src/slack/scopes.ts @@ -0,0 +1,99 @@ +import { WebClient } from "@slack/web-api"; + +export type SlackScopesResult = { + ok: boolean; + scopes?: string[]; + source?: string; + error?: string; +}; + +type SlackScopesSource = "auth.scopes" | "apps.permissions.info"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function collectScopes(value: unknown, into: string[]) { + if (!value) return; + if (Array.isArray(value)) { + for (const entry of value) { + if (typeof entry === "string" && entry.trim()) into.push(entry.trim()); + } + return; + } + if (typeof value === "string") { + const raw = value.trim(); + if (!raw) return; + const parts = raw.split(/[,\s]+/).map((part) => part.trim()); + for (const part of parts) { + if (part) into.push(part); + } + return; + } + if (!isRecord(value)) return; + for (const entry of Object.values(value)) { + if (Array.isArray(entry) || typeof entry === "string") { + collectScopes(entry, into); + } + } +} + +function normalizeScopes(scopes: string[]) { + return Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean))).sort(); +} + +function extractScopes(payload: unknown): string[] { + if (!isRecord(payload)) return []; + const scopes: string[] = []; + collectScopes(payload.scopes, scopes); + collectScopes(payload.scope, scopes); + if (isRecord(payload.info)) { + collectScopes(payload.info.scopes, scopes); + collectScopes(payload.info.scope, scopes); + collectScopes((payload.info as { user_scopes?: unknown }).user_scopes, scopes); + collectScopes((payload.info as { bot_scopes?: unknown }).bot_scopes, scopes); + } + return normalizeScopes(scopes); +} + +function readError(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined; + const error = payload.error; + return typeof error === "string" && error.trim() ? error.trim() : undefined; +} + +async function callSlack( + client: WebClient, + method: SlackScopesSource, +): Promise | null> { + try { + const result = await client.apiCall(method); + return isRecord(result) ? result : null; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function fetchSlackScopes(token: string, timeoutMs: number): Promise { + const client = new WebClient(token, { timeout: timeoutMs }); + const attempts: SlackScopesSource[] = ["auth.scopes", "apps.permissions.info"]; + const errors: string[] = []; + + for (const method of attempts) { + const result = await callSlack(client, method); + const scopes = extractScopes(result); + if (scopes.length > 0) { + return { ok: true, scopes, source: method }; + } + const error = readError(result); + if (error) errors.push(`${method}: ${error}`); + } + + return { + ok: false, + error: errors.length > 0 ? errors.join(" | ") : "no scopes returned", + }; +} diff --git a/src/telegram/probe.ts b/src/telegram/probe.ts index 1dd53422f..acc9d3253 100644 --- a/src/telegram/probe.ts +++ b/src/telegram/probe.ts @@ -7,7 +7,13 @@ export type TelegramProbe = { status?: number | null; error?: string | null; elapsedMs: number; - bot?: { id?: number | null; username?: string | null }; + bot?: { + id?: number | null; + username?: string | null; + canJoinGroups?: boolean | null; + canReadAllGroupMessages?: boolean | null; + supportsInlineQueries?: boolean | null; + }; webhook?: { url?: string | null; hasCustomCert?: boolean | null }; }; @@ -46,7 +52,13 @@ export async function probeTelegram( const meJson = (await meRes.json()) as { ok?: boolean; description?: string; - result?: { id?: number; username?: string }; + result?: { + id?: number; + username?: string; + can_join_groups?: boolean; + can_read_all_group_messages?: boolean; + supports_inline_queries?: boolean; + }; }; if (!meRes.ok || !meJson?.ok) { result.status = meRes.status; @@ -57,6 +69,18 @@ export async function probeTelegram( result.bot = { id: meJson.result?.id ?? null, username: meJson.result?.username ?? null, + canJoinGroups: + typeof meJson.result?.can_join_groups === "boolean" + ? meJson.result?.can_join_groups + : null, + canReadAllGroupMessages: + typeof meJson.result?.can_read_all_group_messages === "boolean" + ? meJson.result?.can_read_all_group_messages + : null, + supportsInlineQueries: + typeof meJson.result?.supports_inline_queries === "boolean" + ? meJson.result?.supports_inline_queries + : null, }; // Try to fetch webhook info, but don't fail health if it errors.