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

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