feat: add per-provider scope probes to channels capabilities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
99
src/slack/scopes.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user