feat: add channels capabilities command

This commit is contained in:
Peter Steinberger
2026-01-17 19:05:33 +00:00
parent 96df70fccf
commit a828e60067
5 changed files with 427 additions and 8 deletions

View File

@@ -5,15 +5,12 @@ Docs: https://docs.clawd.bot
## 2026.1.17 (Unreleased)
### Changes
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
- 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: surface update availability in `clawdbot status`.
- CLI: add `channels capabilities` to summarize channel support and provider probes.
### Fixes
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
## 2026.1.16-2
@@ -57,7 +54,6 @@ Docs: https://docs.clawd.bot
- Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI.
- Memory: add sqlite-vec vector acceleration with CLI status details.
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
- Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
@@ -77,9 +73,6 @@ Docs: https://docs.clawd.bot
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
- Tools: include provider/session context in elevated exec denial errors.
- Tools: normalize exec tool alias naming in tool error logs.
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
- Logging: prefix nested agent output with session/run/channel context.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.

View File

@@ -18,6 +18,8 @@ Related docs:
```bash
clawdbot channels list
clawdbot channels status
clawdbot channels capabilities
clawdbot channels capabilities --channel discord --target channel:123
clawdbot channels logs --channel all
```
@@ -42,3 +44,16 @@ clawdbot channels logout --channel whatsapp
- Run `clawdbot status --deep` for a broad probe.
- Use `clawdbot doctor` for guided fixes.
## Capabilities probe
Fetch provider capability hints (intents/scopes where available) plus static feature support:
```bash
clawdbot channels capabilities
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`.

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { listChannelPlugins } from "../channels/plugins/index.js";
import {
channelsAddCommand,
channelsCapabilitiesCommand,
channelsListCommand,
channelsLogsCommand,
channelsRemoveCommand,
@@ -87,6 +88,23 @@ export function registerChannelsCli(program: Command) {
}
});
channels
.command("capabilities")
.description("Show provider capabilities (intents/scopes + supported features)")
.option("--channel <name>", `Channel (${channelNames}|all)`)
.option("--account <id>", "Account id (only with --channel)")
.option("--target <dest>", "Channel target for permission audit (Discord channel:<id>)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await channelsCapabilitiesCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
channels
.command("logs")
.description("Show recent channel logs from the gateway log file")

View File

@@ -1,5 +1,7 @@
export type { ChannelsAddOptions } from "./channels/add.js";
export { channelsAddCommand } from "./channels/add.js";
export type { ChannelsCapabilitiesOptions } from "./channels/capabilities.js";
export { channelsCapabilitiesCommand } from "./channels/capabilities.js";
export type { ChannelsListOptions } from "./channels/list.js";
export { channelsListCommand } from "./channels/list.js";
export type { ChannelsLogsOptions } from "./channels/logs.js";

View File

@@ -0,0 +1,391 @@
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
import { normalizeDiscordMessagingTarget } from "../../channels/plugins/normalize-target.js";
import type { ChannelCapabilities, ChannelPlugin } from "../../channels/plugins/types.js";
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 { theme } from "../../terminal/theme.js";
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
export type ChannelsCapabilitiesOptions = {
channel?: string;
account?: string;
target?: string;
timeout?: string;
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;
accountName?: string;
configured?: boolean;
enabled?: boolean;
support?: ChannelCapabilities;
probe?: unknown;
target?: DiscordTargetSummary;
channelPermissions?: DiscordPermissionsReport;
};
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
function normalizeTimeout(raw: unknown, fallback = 10_000) {
const value = typeof raw === "string" ? Number(raw) : Number(raw);
if (!Number.isFinite(value) || value <= 0) return fallback;
return value;
}
function formatSupport(capabilities?: ChannelCapabilities) {
if (!capabilities) return "unknown";
const bits: string[] = [];
if (capabilities.chatTypes?.length) {
bits.push(`chatTypes=${capabilities.chatTypes.join(",")}`);
}
if (capabilities.polls) bits.push("polls");
if (capabilities.reactions) bits.push("reactions");
if (capabilities.threads) bits.push("threads");
if (capabilities.media) bits.push("media");
if (capabilities.nativeCommands) bits.push("nativeCommands");
if (capabilities.blockStreaming) bits.push("blockStreaming");
return bits.length ? bits.join(" ") : "none";
}
function summarizeDiscordTarget(raw?: string): DiscordTargetSummary | undefined {
if (!raw) return undefined;
const normalized = normalizeDiscordMessagingTarget(raw);
if (!normalized) return { raw };
if (normalized.startsWith("channel:")) {
return {
raw,
normalized,
kind: "channel",
channelId: normalized.slice("channel:".length),
};
}
if (normalized.startsWith("user:")) {
return {
raw,
normalized,
kind: "user",
};
}
return { raw, 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[] = [];
if (!probe || typeof probe !== "object") return lines;
const probeObj = probe as Record<string, unknown>;
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<string, unknown> } | 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 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}`);
}
}
const ok = typeof probeObj.ok === "boolean" ? probeObj.ok : undefined;
if (ok === true && lines.length === 0) {
lines.push("Probe: ok");
}
if (ok === false) {
const error = typeof probeObj.error === "string" && probeObj.error ? ` (${probeObj.error})` : "";
lines.push(`Probe: ${theme.error(`failed${error}`)}`);
}
return lines;
}
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:<id> 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),
},
};
}
}
async function resolveChannelReports(params: {
plugin: ChannelPlugin;
cfg: ClawdbotConfig;
timeoutMs: number;
accountOverride?: string;
target?: string;
}): Promise<ChannelCapabilitiesReport[]> {
const { plugin, cfg, timeoutMs } = params;
const accountIds = params.accountOverride
? [params.accountOverride]
: (() => {
const ids = plugin.config.listAccountIds(cfg);
return ids.length > 0
? ids
: [resolveChannelDefaultAccountId({ plugin, cfg, accountIds: ids })];
})();
const reports: ChannelCapabilitiesReport[] = [];
for (const accountId of accountIds) {
const resolvedAccount = plugin.config.resolveAccount(cfg, accountId);
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(resolvedAccount, cfg)
: Boolean(resolvedAccount);
const enabled = plugin.config.isEnabled
? plugin.config.isEnabled(resolvedAccount, cfg)
: (resolvedAccount as { enabled?: boolean }).enabled !== false;
let probe: unknown;
if (configured && enabled && plugin.status?.probeAccount) {
try {
probe = await plugin.status.probeAccount({
account: resolvedAccount,
timeoutMs,
cfg,
});
} catch (err) {
probe = { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
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;
}
reports.push({
channel: plugin.id,
accountId,
accountName:
typeof (resolvedAccount as { name?: string }).name === "string"
? (resolvedAccount as { name?: string }).name?.trim() || undefined
: undefined,
configured,
enabled,
support: plugin.capabilities,
probe,
target: discordTarget,
channelPermissions: discordPermissions,
});
}
return reports;
}
export async function channelsCapabilitiesCommand(
opts: ChannelsCapabilitiesOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) return;
const timeoutMs = normalizeTimeout(opts.timeout, 10_000);
const rawChannel =
typeof opts.channel === "string" ? opts.channel.trim().toLowerCase() : "";
const rawTarget = typeof opts.target === "string" ? opts.target.trim() : "";
if (opts.account && (!rawChannel || rawChannel === "all")) {
runtime.error(danger("--account requires a specific --channel."));
runtime.exit(1);
return;
}
if (rawTarget && rawChannel !== "discord") {
runtime.error(danger("--target requires --channel discord."));
runtime.exit(1);
return;
}
const plugins = listChannelPlugins();
const selected =
!rawChannel || rawChannel === "all"
? plugins
: (() => {
const plugin = getChannelPlugin(rawChannel);
if (!plugin) return null;
return [plugin];
})();
if (!selected || selected.length === 0) {
runtime.error(danger(`Unknown channel "${rawChannel}".`));
runtime.exit(1);
return;
}
const reports: ChannelCapabilitiesReport[] = [];
for (const plugin of selected) {
const accountOverride = opts.account?.trim() || undefined;
reports.push(
...(await resolveChannelReports({
plugin,
cfg,
timeoutMs,
accountOverride,
target: rawTarget && plugin.id === "discord" ? rawTarget : undefined,
})),
);
}
if (opts.json) {
runtime.log(JSON.stringify({ channels: reports }, null, 2));
return;
}
const lines: string[] = [];
for (const report of reports) {
const label = formatChannelAccountLabel({
channel: report.channel,
accountId: report.accountId,
name: report.accountName,
channelStyle: theme.accent,
accountStyle: theme.heading,
});
lines.push(theme.heading(label));
lines.push(`Support: ${formatSupport(report.support)}`);
if (report.configured === false || report.enabled === false) {
const configuredLabel = report.configured === false ? "not configured" : "configured";
const enabledLabel = report.enabled === false ? "disabled" : "enabled";
lines.push(`Status: ${configuredLabel}, ${enabledLabel}`);
}
const probeLines = formatProbeLines(report.channel, report.probe);
if (probeLines.length > 0) {
lines.push(...probeLines);
} else if (report.configured && report.enabled) {
lines.push(theme.muted("Probe: unavailable"));
}
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)."));
}
lines.push("");
}
runtime.log(lines.join("\n").trimEnd());
}