feat: add per-provider scope probes to channels capabilities

This commit is contained in:
Peter Steinberger
2026-01-17 19:28:46 +00:00
parent 53218b91c6
commit a7c0887f94
7 changed files with 266 additions and 6 deletions

View File

@@ -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.

View File

@@ -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:<id>` 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`.

View File

@@ -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<ResolvedMSTeamsAccount> = {
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<ResolvedMSTeamsAccount> = {
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
port: runtime?.port ?? null,
probe,
}),
},
gateway: {

View File

@@ -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<string, unknown> | 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<string, unknown>;
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<ProbeMSTeamsResult> {
const creds = resolveMSTeamsCredentials(cfg);
if (!creds) {
@@ -22,7 +66,29 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
return { ok: true, appId: creds.appId };
let graph:
| {
ok: boolean;
error?: string;
roles?: string[];
scopes?: string[];
}
| undefined;
try {
const graphToken = await tokenProvider.getAccessToken(
"https://graph.microsoft.com/.default",
);
const accessToken = readAccessToken(graphToken);
const payload = accessToken ? decodeJwtPayload(accessToken) : null;
graph = {
ok: true,
roles: readStringArray(payload?.roles),
scopes: readScopes(payload?.scp),
};
} catch (err) {
graph = { ok: false, error: formatUnknownError(err) };
}
return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) };
} catch (err) {
return {
ok: false,

View File

@@ -6,6 +6,7 @@ import { fetchChannelPermissionsDiscord } from "../../discord/send.js";
import { danger } from "../../globals.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { fetchSlackScopes, type SlackScopesResult } from "../../slack/scopes.js";
import { theme } from "../../terminal/theme.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
@@ -42,7 +43,9 @@ type ChannelCapabilitiesReport = {
configured?: boolean;
enabled?: boolean;
support?: ChannelCapabilities;
actions?: string[];
probe?: unknown;
scopes?: SlackScopesResult;
target?: DiscordTargetSummary;
channelPermissions?: DiscordPermissionsReport;
};
@@ -128,6 +131,14 @@ function formatProbeLines(channelId: string, probe: unknown): string[] {
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"}`);
@@ -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<string>(["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) {

99
src/slack/scopes.ts Normal file
View File

@@ -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<string, unknown> {
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<Record<string, unknown> | 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<SlackScopesResult> {
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",
};
}

View File

@@ -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.