mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:48:26 +00:00
refactor!: rename chat providers to channels
This commit is contained in:
67
src/commands/channels/add-mutators.ts
Normal file
67
src/commands/channels/add-mutators.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type {
|
||||
ChannelId,
|
||||
ChannelSetupInput,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
|
||||
type ChatChannel = ChannelId;
|
||||
|
||||
export function applyAccountName(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChatChannel;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}): ClawdbotConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const apply = plugin?.setup?.applyAccountName;
|
||||
return apply
|
||||
? apply({ cfg: params.cfg, accountId, name: params.name })
|
||||
: params.cfg;
|
||||
}
|
||||
|
||||
export function applyChannelAccountConfig(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: ChatChannel;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
token?: string;
|
||||
tokenFile?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
signalNumber?: string;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
region?: string;
|
||||
authDir?: string;
|
||||
httpUrl?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: string;
|
||||
useEnv?: boolean;
|
||||
}): ClawdbotConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const apply = plugin?.setup?.applyAccountConfig;
|
||||
if (!apply) return params.cfg;
|
||||
const input: ChannelSetupInput = {
|
||||
name: params.name,
|
||||
token: params.token,
|
||||
tokenFile: params.tokenFile,
|
||||
botToken: params.botToken,
|
||||
appToken: params.appToken,
|
||||
signalNumber: params.signalNumber,
|
||||
cliPath: params.cliPath,
|
||||
dbPath: params.dbPath,
|
||||
service: params.service,
|
||||
region: params.region,
|
||||
authDir: params.authDir,
|
||||
httpUrl: params.httpUrl,
|
||||
httpHost: params.httpHost,
|
||||
httpPort: params.httpPort,
|
||||
useEnv: params.useEnv,
|
||||
};
|
||||
return apply({ cfg: params.cfg, accountId, input });
|
||||
}
|
||||
168
src/commands/channels/add.ts
Normal file
168
src/commands/channels/add.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
getChannelPlugin,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { writeConfigFile } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import { setupChannels } from "../onboard-channels.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
|
||||
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
|
||||
|
||||
export type ChannelsAddOptions = {
|
||||
channel?: string;
|
||||
account?: string;
|
||||
name?: string;
|
||||
token?: string;
|
||||
tokenFile?: string;
|
||||
botToken?: string;
|
||||
appToken?: string;
|
||||
signalNumber?: string;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
region?: string;
|
||||
authDir?: string;
|
||||
httpUrl?: string;
|
||||
httpHost?: string;
|
||||
httpPort?: string;
|
||||
useEnv?: boolean;
|
||||
};
|
||||
|
||||
export async function channelsAddCommand(
|
||||
opts: ChannelsAddOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
params?: { hasFlags?: boolean },
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
|
||||
const useWizard = shouldUseWizard(params);
|
||||
if (useWizard) {
|
||||
const prompter = createClackPrompter();
|
||||
let selection: ChannelChoice[] = [];
|
||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
await prompter.intro("Channel setup");
|
||||
let nextConfig = await setupChannels(cfg, runtime, prompter, {
|
||||
allowDisable: false,
|
||||
allowSignalInstall: true,
|
||||
promptAccountIds: true,
|
||||
onSelection: (value) => {
|
||||
selection = value;
|
||||
},
|
||||
onAccountId: (channel, accountId) => {
|
||||
accountIds[channel] = accountId;
|
||||
},
|
||||
});
|
||||
if (selection.length === 0) {
|
||||
await prompter.outro("No channels selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const wantsNames = await prompter.confirm({
|
||||
message: "Add display names for these accounts? (optional)",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsNames) {
|
||||
for (const channel of selection) {
|
||||
const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID;
|
||||
const plugin = getChannelPlugin(channel as ChannelId);
|
||||
const account = plugin?.config.resolveAccount(nextConfig, accountId) as
|
||||
| { name?: string }
|
||||
| undefined;
|
||||
const snapshot = plugin?.config.describeAccount?.(account, nextConfig);
|
||||
const existingName = snapshot?.name ?? account?.name;
|
||||
const name = await prompter.text({
|
||||
message: `${channel} account name (${accountId})`,
|
||||
initialValue: existingName,
|
||||
});
|
||||
if (name?.trim()) {
|
||||
nextConfig = applyAccountName({
|
||||
cfg: nextConfig,
|
||||
channel,
|
||||
accountId,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
await prompter.outro("Channels updated.");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = normalizeChannelId(opts.channel);
|
||||
if (!channel) {
|
||||
runtime.error(`Unknown channel: ${String(opts.channel ?? "")}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (!plugin?.setup?.applyAccountConfig) {
|
||||
runtime.error(`Channel ${channel} does not support add.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const accountId =
|
||||
plugin.setup.resolveAccountId?.({ cfg, accountId: opts.account }) ??
|
||||
normalizeAccountId(opts.account);
|
||||
const useEnv = opts.useEnv === true;
|
||||
const validationError = plugin.setup.validateInput?.({
|
||||
cfg,
|
||||
accountId,
|
||||
input: {
|
||||
name: opts.name,
|
||||
token: opts.token,
|
||||
tokenFile: opts.tokenFile,
|
||||
botToken: opts.botToken,
|
||||
appToken: opts.appToken,
|
||||
signalNumber: opts.signalNumber,
|
||||
cliPath: opts.cliPath,
|
||||
dbPath: opts.dbPath,
|
||||
service: opts.service,
|
||||
region: opts.region,
|
||||
authDir: opts.authDir,
|
||||
httpUrl: opts.httpUrl,
|
||||
httpHost: opts.httpHost,
|
||||
httpPort: opts.httpPort,
|
||||
useEnv,
|
||||
},
|
||||
});
|
||||
if (validationError) {
|
||||
runtime.error(validationError);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfig = applyChannelAccountConfig({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
name: opts.name,
|
||||
token: opts.token,
|
||||
tokenFile: opts.tokenFile,
|
||||
botToken: opts.botToken,
|
||||
appToken: opts.appToken,
|
||||
signalNumber: opts.signalNumber,
|
||||
cliPath: opts.cliPath,
|
||||
dbPath: opts.dbPath,
|
||||
service: opts.service,
|
||||
region: opts.region,
|
||||
authDir: opts.authDir,
|
||||
httpUrl: opts.httpUrl,
|
||||
httpHost: opts.httpHost,
|
||||
httpPort: opts.httpPort,
|
||||
useEnv,
|
||||
});
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
|
||||
}
|
||||
196
src/commands/channels/list.ts
Normal file
196
src/commands/channels/list.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
loadAuthProfileStore,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { withProgress } from "../../cli/progress.js";
|
||||
import {
|
||||
formatUsageReportLines,
|
||||
loadProviderUsageSummary,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { formatChannelAccountLabel, requireValidConfig } from "./shared.js";
|
||||
|
||||
export type ChannelsListOptions = {
|
||||
json?: boolean;
|
||||
usage?: boolean;
|
||||
};
|
||||
|
||||
const colorValue = (value: string) => {
|
||||
if (value === "none") return theme.error(value);
|
||||
if (value === "env") return theme.accent(value);
|
||||
return theme.success(value);
|
||||
};
|
||||
|
||||
function formatEnabled(value: boolean | undefined): string {
|
||||
return value === false ? theme.error("disabled") : theme.success("enabled");
|
||||
}
|
||||
|
||||
function formatConfigured(value: boolean): string {
|
||||
return value ? theme.success("configured") : theme.warn("not configured");
|
||||
}
|
||||
|
||||
function formatTokenSource(source?: string): string {
|
||||
const value = source || "none";
|
||||
return `token=${colorValue(value)}`;
|
||||
}
|
||||
|
||||
function formatSource(label: string, source?: string): string {
|
||||
const value = source || "none";
|
||||
return `${label}=${colorValue(value)}`;
|
||||
}
|
||||
|
||||
function formatLinked(value: boolean): string {
|
||||
return value ? theme.success("linked") : theme.warn("not linked");
|
||||
}
|
||||
|
||||
function shouldShowConfigured(channel: ChannelPlugin): boolean {
|
||||
return channel.meta.showConfigured !== false;
|
||||
}
|
||||
|
||||
function formatAccountLine(params: {
|
||||
channel: ChannelPlugin;
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
}): string {
|
||||
const { channel, snapshot } = params;
|
||||
const label = formatChannelAccountLabel({
|
||||
channel: channel.id,
|
||||
accountId: snapshot.accountId,
|
||||
name: snapshot.name,
|
||||
channelStyle: theme.accent,
|
||||
accountStyle: theme.heading,
|
||||
});
|
||||
const bits: string[] = [];
|
||||
if (snapshot.linked !== undefined) {
|
||||
bits.push(formatLinked(snapshot.linked));
|
||||
}
|
||||
if (
|
||||
shouldShowConfigured(channel) &&
|
||||
typeof snapshot.configured === "boolean"
|
||||
) {
|
||||
bits.push(formatConfigured(snapshot.configured));
|
||||
}
|
||||
if (snapshot.tokenSource) {
|
||||
bits.push(formatTokenSource(snapshot.tokenSource));
|
||||
}
|
||||
if (snapshot.botTokenSource) {
|
||||
bits.push(formatSource("bot", snapshot.botTokenSource));
|
||||
}
|
||||
if (snapshot.appTokenSource) {
|
||||
bits.push(formatSource("app", snapshot.appTokenSource));
|
||||
}
|
||||
if (snapshot.baseUrl) {
|
||||
bits.push(`base=${theme.muted(snapshot.baseUrl)}`);
|
||||
}
|
||||
if (typeof snapshot.enabled === "boolean") {
|
||||
bits.push(formatEnabled(snapshot.enabled));
|
||||
}
|
||||
return `- ${label}: ${bits.join(", ")}`;
|
||||
}
|
||||
async function loadUsageWithProgress(
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<Awaited<ReturnType<typeof loadProviderUsageSummary>> | null> {
|
||||
try {
|
||||
return await withProgress(
|
||||
{ label: "Fetching usage snapshot…", indeterminate: true, enabled: true },
|
||||
async () => await loadProviderUsageSummary(),
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(String(err));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelsListCommand(
|
||||
opts: ChannelsListOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
const includeUsage = opts.usage !== false;
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
|
||||
const authStore = loadAuthProfileStore();
|
||||
const authProfiles = Object.entries(authStore.profiles).map(
|
||||
([profileId, profile]) => ({
|
||||
id: profileId,
|
||||
provider: profile.provider,
|
||||
type: profile.type,
|
||||
isExternal:
|
||||
profileId === CLAUDE_CLI_PROFILE_ID ||
|
||||
profileId === CODEX_CLI_PROFILE_ID,
|
||||
}),
|
||||
);
|
||||
if (opts.json) {
|
||||
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;
|
||||
const chat: Record<string, string[]> = {};
|
||||
for (const plugin of plugins) {
|
||||
chat[plugin.id] = plugin.config.listAccountIds(cfg);
|
||||
}
|
||||
const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) };
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.heading("Chat channels:"));
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const accounts = plugin.config.listAccountIds(cfg);
|
||||
if (!accounts || accounts.length === 0) continue;
|
||||
for (const accountId of accounts) {
|
||||
const snapshot = await buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
lines.push(
|
||||
formatAccountLine({
|
||||
channel: plugin,
|
||||
snapshot,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Auth providers (OAuth + API keys):"));
|
||||
if (authProfiles.length === 0) {
|
||||
lines.push(theme.muted("- none"));
|
||||
} else {
|
||||
for (const profile of authProfiles) {
|
||||
const external = profile.isExternal ? theme.muted(" (synced)") : "";
|
||||
lines.push(
|
||||
`- ${theme.accent(profile.id)} (${theme.success(profile.type)}${external})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log(lines.join("\n"));
|
||||
|
||||
if (includeUsage) {
|
||||
runtime.log("");
|
||||
const usage = await loadUsageWithProgress(runtime);
|
||||
if (usage) {
|
||||
const usageLines = formatUsageReportLines(usage);
|
||||
if (usageLines.length > 0) {
|
||||
usageLines[0] = theme.accent(usageLines[0]);
|
||||
runtime.log(usageLines.join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(
|
||||
`Docs: ${formatDocsLink("/gateway/configuration", "gateway/configuration")}`,
|
||||
);
|
||||
}
|
||||
101
src/commands/channels/logs.ts
Normal file
101
src/commands/channels/logs.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { parseLogLine } from "../../logging/parse-log-line.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
|
||||
export type ChannelsLogsOptions = {
|
||||
channel?: string;
|
||||
lines?: string | number;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type LogLine = ReturnType<typeof parseLogLine>;
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_BYTES = 1_000_000;
|
||||
const CHANNELS = new Set<string>([
|
||||
...listChannelPlugins().map((plugin) => plugin.id),
|
||||
"all",
|
||||
]);
|
||||
|
||||
function parseChannelFilter(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase();
|
||||
if (!trimmed) return "all";
|
||||
return CHANNELS.has(trimmed) ? trimmed : "all";
|
||||
}
|
||||
|
||||
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
|
||||
if (channel === "all") return true;
|
||||
const needle = `gateway/channels/${channel}`;
|
||||
if (line.subsystem?.includes(needle)) return true;
|
||||
if (line.module?.includes(channel)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readTailLines(file: string, limit: number): Promise<string[]> {
|
||||
const stat = await fs.stat(file).catch(() => null);
|
||||
if (!stat) return [];
|
||||
const size = stat.size;
|
||||
const start = Math.max(0, size - MAX_BYTES);
|
||||
const handle = await fs.open(file, "r");
|
||||
try {
|
||||
const length = Math.max(0, size - start);
|
||||
if (length === 0) return [];
|
||||
const buffer = Buffer.alloc(length);
|
||||
const readResult = await handle.read(buffer, 0, length, start);
|
||||
const text = buffer.toString("utf8", 0, readResult.bytesRead);
|
||||
let lines = text.split("\n");
|
||||
if (start > 0) lines = lines.slice(1);
|
||||
if (lines.length && lines[lines.length - 1] === "") {
|
||||
lines = lines.slice(0, -1);
|
||||
}
|
||||
if (lines.length > limit) {
|
||||
lines = lines.slice(lines.length - limit);
|
||||
}
|
||||
return lines;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function channelsLogsCommand(
|
||||
opts: ChannelsLogsOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const channel = parseChannelFilter(opts.channel);
|
||||
const limitRaw =
|
||||
typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
|
||||
const limit =
|
||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.floor(limitRaw)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const file = getResolvedLoggerSettings().file;
|
||||
const rawLines = await readTailLines(file, limit * 4);
|
||||
const parsed = rawLines
|
||||
.map(parseLogLine)
|
||||
.filter((line): line is NonNullable<LogLine> => Boolean(line));
|
||||
const filtered = parsed.filter((line) => matchesChannel(line, channel));
|
||||
const lines = filtered.slice(Math.max(0, filtered.length - limit));
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ file, channel, lines }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.log(theme.info(`Log file: ${file}`));
|
||||
if (channel !== "all") {
|
||||
runtime.log(theme.info(`Channel: ${channel}`));
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
runtime.log(theme.muted("No matching log lines."));
|
||||
return;
|
||||
}
|
||||
for (const line of lines) {
|
||||
const ts = line.time ? `${line.time} ` : "";
|
||||
const level = line.level ? `${line.level.toLowerCase()} ` : "";
|
||||
runtime.log(`${ts}${level}${line.message}`.trim());
|
||||
}
|
||||
}
|
||||
147
src/commands/channels/remove.ts
Normal file
147
src/commands/channels/remove.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||
import {
|
||||
getChannelPlugin,
|
||||
listChannelPlugins,
|
||||
normalizeChannelId,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import { type ClawdbotConfig, writeConfigFile } from "../../config/config.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { createClackPrompter } from "../../wizard/clack-prompter.js";
|
||||
import {
|
||||
type ChatChannel,
|
||||
channelLabel,
|
||||
requireValidConfig,
|
||||
shouldUseWizard,
|
||||
} from "./shared.js";
|
||||
|
||||
export type ChannelsRemoveOptions = {
|
||||
channel?: string;
|
||||
account?: string;
|
||||
delete?: boolean;
|
||||
};
|
||||
|
||||
function listAccountIds(cfg: ClawdbotConfig, channel: ChatChannel): string[] {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (!plugin) return [];
|
||||
return plugin.config.listAccountIds(cfg);
|
||||
}
|
||||
|
||||
export async function channelsRemoveCommand(
|
||||
opts: ChannelsRemoveOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
params?: { hasFlags?: boolean },
|
||||
) {
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
|
||||
const useWizard = shouldUseWizard(params);
|
||||
const prompter = useWizard ? createClackPrompter() : null;
|
||||
let channel: ChatChannel | null = normalizeChannelId(opts.channel);
|
||||
let accountId = normalizeAccountId(opts.account);
|
||||
const deleteConfig = Boolean(opts.delete);
|
||||
|
||||
if (useWizard && prompter) {
|
||||
await prompter.intro("Remove channel account");
|
||||
const selectedChannel = (await prompter.select({
|
||||
message: "Channel",
|
||||
options: listChannelPlugins().map((plugin) => ({
|
||||
value: plugin.id,
|
||||
label: plugin.meta.label,
|
||||
})),
|
||||
})) as ChatChannel;
|
||||
channel = selectedChannel;
|
||||
|
||||
accountId = await (async () => {
|
||||
const ids = listAccountIds(cfg, selectedChannel);
|
||||
const choice = (await prompter.select({
|
||||
message: "Account",
|
||||
options: ids.map((id) => ({
|
||||
value: id,
|
||||
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||
})),
|
||||
initialValue: ids[0] ?? DEFAULT_ACCOUNT_ID,
|
||||
})) as string;
|
||||
return normalizeAccountId(choice);
|
||||
})();
|
||||
|
||||
const wantsDisable = await prompter.confirm({
|
||||
message: `Disable ${channelLabel(selectedChannel)} account "${accountId}"? (keeps config)`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!wantsDisable) {
|
||||
await prompter.outro("Cancelled.");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!channel) {
|
||||
runtime.error("Channel is required. Use --channel <name>.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!deleteConfig) {
|
||||
const confirm = createClackPrompter();
|
||||
const ok = await confirm.confirm({
|
||||
message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = getChannelPlugin(channel);
|
||||
if (!plugin) {
|
||||
runtime.error(`Unknown channel: ${channel}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAccountId =
|
||||
normalizeAccountId(accountId) ??
|
||||
resolveChannelDefaultAccountId({ plugin, cfg });
|
||||
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
|
||||
|
||||
let next = { ...cfg };
|
||||
if (deleteConfig) {
|
||||
if (!plugin.config.deleteAccount) {
|
||||
runtime.error(`Channel ${channel} does not support delete.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
next = plugin.config.deleteAccount({
|
||||
cfg: next,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
} else {
|
||||
if (!plugin.config.setAccountEnabled) {
|
||||
runtime.error(`Channel ${channel} does not support disable.`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
next = plugin.config.setAccountEnabled({
|
||||
cfg: next,
|
||||
accountId: resolvedAccountId,
|
||||
enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
await writeConfigFile(next);
|
||||
if (useWizard && prompter) {
|
||||
await prompter.outro(
|
||||
deleteConfig
|
||||
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
|
||||
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
|
||||
);
|
||||
} else {
|
||||
runtime.log(
|
||||
deleteConfig
|
||||
? `Deleted ${channelLabel(channel)} account "${accountKey}".`
|
||||
: `Disabled ${channelLabel(channel)} account "${accountKey}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/commands/channels/shared.ts
Normal file
70
src/commands/channels/shared.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
type ChannelId,
|
||||
getChannelPlugin,
|
||||
} from "../../channels/plugins/index.js";
|
||||
import {
|
||||
type ClawdbotConfig,
|
||||
readConfigFileSnapshot,
|
||||
} from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
export type ChatChannel = ChannelId;
|
||||
|
||||
export async function requireValidConfig(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<ClawdbotConfig | null> {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
const issues =
|
||||
snapshot.issues.length > 0
|
||||
? snapshot.issues
|
||||
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
.join("\n")
|
||||
: "Unknown validation issue.";
|
||||
runtime.error(`Config invalid:\n${issues}`);
|
||||
runtime.error("Fix the config or run clawdbot doctor.");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
return snapshot.config;
|
||||
}
|
||||
|
||||
export function formatAccountLabel(params: {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const base = params.accountId || DEFAULT_ACCOUNT_ID;
|
||||
if (params.name?.trim()) return `${base} (${params.name.trim()})`;
|
||||
return base;
|
||||
}
|
||||
|
||||
export const channelLabel = (channel: ChatChannel) => {
|
||||
const plugin = getChannelPlugin(channel);
|
||||
return plugin?.meta.label ?? channel;
|
||||
};
|
||||
|
||||
export function formatChannelAccountLabel(params: {
|
||||
channel: ChatChannel;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
channelStyle?: (value: string) => string;
|
||||
accountStyle?: (value: string) => string;
|
||||
}): string {
|
||||
const channelText = channelLabel(params.channel);
|
||||
const accountText = formatAccountLabel({
|
||||
accountId: params.accountId,
|
||||
name: params.name,
|
||||
});
|
||||
const styledChannel = params.channelStyle
|
||||
? params.channelStyle(channelText)
|
||||
: channelText;
|
||||
const styledAccount = params.accountStyle
|
||||
? params.accountStyle(accountText)
|
||||
: accountText;
|
||||
return `${styledChannel} ${styledAccount}`;
|
||||
}
|
||||
|
||||
export function shouldUseWizard(params?: { hasFlags?: boolean }) {
|
||||
return params?.hasFlags === false;
|
||||
}
|
||||
301
src/commands/channels/status.ts
Normal file
301
src/commands/channels/status.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
|
||||
import { withProgress } from "../../cli/progress.js";
|
||||
import {
|
||||
type ClawdbotConfig,
|
||||
readConfigFileSnapshot,
|
||||
} from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { formatAge } from "../../infra/channel-summary.js";
|
||||
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import {
|
||||
type ChatChannel,
|
||||
formatChannelAccountLabel,
|
||||
requireValidConfig,
|
||||
} from "./shared.js";
|
||||
|
||||
export type ChannelsStatusOptions = {
|
||||
json?: boolean;
|
||||
probe?: boolean;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
export function formatGatewayChannelsStatusLines(
|
||||
payload: Record<string, unknown>,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.success("Gateway reachable."));
|
||||
const accountLines = (
|
||||
provider: ChatChannel,
|
||||
accounts: Array<Record<string, unknown>>,
|
||||
) =>
|
||||
accounts.map((account) => {
|
||||
const bits: string[] = [];
|
||||
if (typeof account.enabled === "boolean") {
|
||||
bits.push(account.enabled ? "enabled" : "disabled");
|
||||
}
|
||||
if (typeof account.configured === "boolean") {
|
||||
bits.push(account.configured ? "configured" : "not configured");
|
||||
}
|
||||
if (typeof account.linked === "boolean") {
|
||||
bits.push(account.linked ? "linked" : "not linked");
|
||||
}
|
||||
if (typeof account.running === "boolean") {
|
||||
bits.push(account.running ? "running" : "stopped");
|
||||
}
|
||||
if (typeof account.connected === "boolean") {
|
||||
bits.push(account.connected ? "connected" : "disconnected");
|
||||
}
|
||||
const inboundAt =
|
||||
typeof account.lastInboundAt === "number" &&
|
||||
Number.isFinite(account.lastInboundAt)
|
||||
? account.lastInboundAt
|
||||
: null;
|
||||
const outboundAt =
|
||||
typeof account.lastOutboundAt === "number" &&
|
||||
Number.isFinite(account.lastOutboundAt)
|
||||
? account.lastOutboundAt
|
||||
: null;
|
||||
if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
|
||||
if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
|
||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||
bits.push(`mode:${account.mode}`);
|
||||
}
|
||||
if (typeof account.dmPolicy === "string" && account.dmPolicy.length > 0) {
|
||||
bits.push(`dm:${account.dmPolicy}`);
|
||||
}
|
||||
if (Array.isArray(account.allowFrom) && account.allowFrom.length > 0) {
|
||||
bits.push(`allow:${account.allowFrom.slice(0, 2).join(",")}`);
|
||||
}
|
||||
if (typeof account.tokenSource === "string" && account.tokenSource) {
|
||||
bits.push(`token:${account.tokenSource}`);
|
||||
}
|
||||
if (
|
||||
typeof account.botTokenSource === "string" &&
|
||||
account.botTokenSource
|
||||
) {
|
||||
bits.push(`bot:${account.botTokenSource}`);
|
||||
}
|
||||
if (
|
||||
typeof account.appTokenSource === "string" &&
|
||||
account.appTokenSource
|
||||
) {
|
||||
bits.push(`app:${account.appTokenSource}`);
|
||||
}
|
||||
const application = account.application as
|
||||
| { intents?: { messageContent?: string } }
|
||||
| undefined;
|
||||
const messageContent = application?.intents?.messageContent;
|
||||
if (
|
||||
typeof messageContent === "string" &&
|
||||
messageContent.length > 0 &&
|
||||
messageContent !== "enabled"
|
||||
) {
|
||||
bits.push(`intents:content=${messageContent}`);
|
||||
}
|
||||
if (account.allowUnmentionedGroups === true) {
|
||||
bits.push("groups:unmentioned");
|
||||
}
|
||||
if (typeof account.baseUrl === "string" && account.baseUrl) {
|
||||
bits.push(`url:${account.baseUrl}`);
|
||||
}
|
||||
const probe = account.probe as { ok?: boolean } | undefined;
|
||||
if (probe && typeof probe.ok === "boolean") {
|
||||
bits.push(probe.ok ? "works" : "probe failed");
|
||||
}
|
||||
const audit = account.audit as { ok?: boolean } | undefined;
|
||||
if (audit && typeof audit.ok === "boolean") {
|
||||
bits.push(audit.ok ? "audit ok" : "audit failed");
|
||||
}
|
||||
if (typeof account.lastError === "string" && account.lastError) {
|
||||
bits.push(`error:${account.lastError}`);
|
||||
}
|
||||
const accountId =
|
||||
typeof account.accountId === "string" ? account.accountId : "default";
|
||||
const name = typeof account.name === "string" ? account.name.trim() : "";
|
||||
const labelText = formatChannelAccountLabel({
|
||||
channel: provider,
|
||||
accountId,
|
||||
name: name || undefined,
|
||||
});
|
||||
return `- ${labelText}: ${bits.join(", ")}`;
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const accountsByChannel = payload.channelAccounts as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const accountPayloads: Partial<
|
||||
Record<string, Array<Record<string, unknown>>>
|
||||
> = {};
|
||||
for (const plugin of plugins) {
|
||||
const raw = accountsByChannel?.[plugin.id];
|
||||
if (Array.isArray(raw)) {
|
||||
accountPayloads[plugin.id] = raw as Array<Record<string, unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const accounts = accountPayloads[plugin.id];
|
||||
if (accounts && accounts.length > 0) {
|
||||
lines.push(...accountLines(plugin.id as ChatChannel, accounts));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
const issues = collectChannelStatusIssues(payload);
|
||||
if (issues.length > 0) {
|
||||
lines.push(theme.warn("Warnings:"));
|
||||
for (const issue of issues) {
|
||||
lines.push(
|
||||
`- ${issue.channel} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push(`- Run: clawdbot doctor`);
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function formatConfigChannelsStatusLines(
|
||||
cfg: ClawdbotConfig,
|
||||
meta: { path?: string; mode?: "local" | "remote" },
|
||||
): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.warn("Gateway not reachable; showing config-only status."));
|
||||
if (meta.path) {
|
||||
lines.push(`Config: ${meta.path}`);
|
||||
}
|
||||
if (meta.mode) {
|
||||
lines.push(`Mode: ${meta.mode}`);
|
||||
}
|
||||
if (meta.path || meta.mode) lines.push("");
|
||||
|
||||
const accountLines = (
|
||||
provider: ChatChannel,
|
||||
accounts: Array<Record<string, unknown>>,
|
||||
) =>
|
||||
accounts.map((account) => {
|
||||
const bits: string[] = [];
|
||||
if (typeof account.enabled === "boolean") {
|
||||
bits.push(account.enabled ? "enabled" : "disabled");
|
||||
}
|
||||
if (typeof account.configured === "boolean") {
|
||||
bits.push(account.configured ? "configured" : "not configured");
|
||||
}
|
||||
if (typeof account.linked === "boolean") {
|
||||
bits.push(account.linked ? "linked" : "not linked");
|
||||
}
|
||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||
bits.push(`mode:${account.mode}`);
|
||||
}
|
||||
if (typeof account.tokenSource === "string" && account.tokenSource) {
|
||||
bits.push(`token:${account.tokenSource}`);
|
||||
}
|
||||
if (
|
||||
typeof account.botTokenSource === "string" &&
|
||||
account.botTokenSource
|
||||
) {
|
||||
bits.push(`bot:${account.botTokenSource}`);
|
||||
}
|
||||
if (
|
||||
typeof account.appTokenSource === "string" &&
|
||||
account.appTokenSource
|
||||
) {
|
||||
bits.push(`app:${account.appTokenSource}`);
|
||||
}
|
||||
if (typeof account.baseUrl === "string" && account.baseUrl) {
|
||||
bits.push(`url:${account.baseUrl}`);
|
||||
}
|
||||
const accountId =
|
||||
typeof account.accountId === "string" ? account.accountId : "default";
|
||||
const name = typeof account.name === "string" ? account.name.trim() : "";
|
||||
const labelText = formatChannelAccountLabel({
|
||||
channel: provider,
|
||||
accountId,
|
||||
name: name || undefined,
|
||||
});
|
||||
return `- ${labelText}: ${bits.join(", ")}`;
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
for (const plugin of plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (!accountIds.length) continue;
|
||||
const snapshots: ChannelAccountSnapshot[] = [];
|
||||
for (const accountId of accountIds) {
|
||||
const snapshot = await buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
if (snapshots.length > 0) {
|
||||
lines.push(...accountLines(plugin.id as ChatChannel, snapshots));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`Tip: ${formatDocsLink("/cli#status", "status --deep")} adds gateway health probes to status output (requires a reachable gateway).`,
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function channelsStatusCommand(
|
||||
opts: ChannelsStatusOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
) {
|
||||
const timeoutMs = Number(opts.timeout ?? 10_000);
|
||||
const statusLabel = opts.probe
|
||||
? "Checking channel status (probe)…"
|
||||
: "Checking channel status…";
|
||||
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
|
||||
if (shouldLogStatus) runtime.log(statusLabel);
|
||||
try {
|
||||
const payload = await withProgress(
|
||||
{
|
||||
label: statusLabel,
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
method: "channels.status",
|
||||
params: { probe: Boolean(opts.probe), timeoutMs },
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
runtime.log(
|
||||
formatGatewayChannelsStatusLines(payload as Record<string, unknown>).join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway not reachable: ${String(err)}`);
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
if (!cfg) return;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
runtime.log(
|
||||
(
|
||||
await formatConfigChannelsStatusLines(cfg, {
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
})
|
||||
).join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user