| |
| import { Option, type Command } from "commander"; |
| import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; |
| import { theme } from "../../packages/terminal-core/src/theme.js"; |
| import { parseAccountSelector } from "../commands/channels/account-selector.js"; |
| import { danger } from "../globals.js"; |
| import { formatErrorMessage } from "../infra/errors.js"; |
| import { defaultRuntime } from "../runtime.js"; |
| import { createLazyPromise } from "../shared/lazy-promise.js"; |
| import { resolveCliArgvInvocation } from "./argv-invocation.js"; |
| import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; |
| import { formatCliChannelOptions } from "./channel-options.js"; |
| import { |
| getChannelSetupOptionSwitches, |
| loadChannelSetupCliOptions, |
| resolveChannelsAddChannelFromArgv, |
| resolveChannelsAddOptions, |
| type ChannelSetupCliOption, |
| } from "./channels-cli-add-args.js"; |
| import { runCommandWithRuntime } from "./cli-utils.js"; |
| import { hasExplicitOptions, inheritOptionFromParent } from "./command-options.js"; |
| import { formatHelpExamples } from "./help-format.js"; |
| import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; |
| import { normalizeWindowsArgv } from "./windows-argv.js"; |
|
|
| const optionNamesRemove = ["channel", "account", "delete"] as const; |
| const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["agent", "channel"]); |
|
|
| type RegisterChannelsCliOptions = { |
| includeSetupOptions?: boolean; |
| }; |
|
|
| type AddChannelSetupOptionsParams = { |
| channelId?: string; |
| includeAll?: boolean; |
| }; |
|
|
| type ChannelSetupOptionMode = "none" | "modern" | "legacy"; |
|
|
| type ChannelSetupOptionRegistration = { |
| mode: ChannelSetupOptionMode; |
| legacyIntDefaultAttributeNames: ReadonlySet<string>; |
| }; |
|
|
| const LEGACY_CHANNEL_SETUP_OPTIONS: readonly ChannelSetupCliOption[] = [ |
| { flags: "--token <token>", description: "Channel token or credential payload" }, |
| { |
| flags: "--token-file <path>", |
| description: "Read channel token or credential payload from file", |
| }, |
| { flags: "--secret <secret>", description: "Channel shared secret" }, |
| { flags: "--bot-token <token>", description: "Bot token" }, |
| { flags: "--app-token <token>", description: "App token" }, |
| { flags: "--password <password>", description: "Channel password or login secret" }, |
| { flags: "--cli-path <path>", description: "Channel CLI path" }, |
| { flags: "--url <url>", description: "Channel setup URL" }, |
| { flags: "--base-url <url>", description: "Channel base URL" }, |
| { flags: "--http-url <url>", description: "Channel HTTP service URL" }, |
| { flags: "--auth-dir <path>", description: "Channel auth directory override" }, |
| { |
| flags: "--use-env", |
| description: "Use env-backed credentials when supported", |
| defaultValue: false, |
| }, |
| ]; |
|
|
| const loadChannelsCommands = createLazyPromise(() => import("../commands/channels.js")); |
|
|
| function runChannelsCommand(action: () => Promise<void>) { |
| return runCommandWithRuntime(defaultRuntime, action); |
| } |
|
|
| function runChannelsCommandWithDanger(action: () => Promise<void>, label: string) { |
| return runCommandWithRuntime(defaultRuntime, action, (err) => { |
| defaultRuntime.error(danger(`${label}: ${formatErrorMessage(err)}`)); |
| defaultRuntime.exit(1); |
| }); |
| } |
|
|
| function getOptionNames(command: Command): string[] { |
| return command.options.map((option) => option.attributeName()); |
| } |
|
|
| function resolveStringOption(command: Command, name: string): string | undefined { |
| const source = command.getOptionValueSource(name); |
| const localValue = command.getOptionValue(name); |
| const value = |
| source && source !== "default" |
| ? localValue |
| : (inheritOptionFromParent<string>(command, name) ?? localValue); |
| return typeof value === "string" ? value : undefined; |
| } |
|
|
| function addChannelSetupOption( |
| command: Command, |
| option: ChannelSetupCliOption, |
| seenFlags: Set<string>, |
| ): void { |
| const prepared = command.createOption(option.flags, option.description); |
| const optionSwitches = getChannelSetupOptionSwitches(prepared); |
| if (optionSwitches.some((flag) => seenFlags.has(flag))) { |
| return; |
| } |
| optionSwitches.forEach((flag) => seenFlags.add(flag)); |
| command.addOption(prepared.makeOptionMandatory(false).default(option.defaultValue)); |
| if (option.negatedFlags) { |
| const negated = command.createOption(option.negatedFlags, option.description); |
| const negatedSwitches = getChannelSetupOptionSwitches(negated); |
| if (!negatedSwitches.some((flag) => seenFlags.has(flag))) { |
| negatedSwitches.forEach((flag) => seenFlags.add(flag)); |
| command.addOption(negated.makeOptionMandatory(false).default(undefined)); |
| } |
| } |
| } |
|
|
| function shouldRegisterChannelSetupOptions( |
| argv: string[] = process.argv, |
| options: RegisterChannelsCliOptions = {}, |
| ): boolean { |
| |
| if (options.includeSetupOptions) { |
| return true; |
| } |
| const { commandPath } = resolveCliArgvInvocation(normalizeWindowsArgv(argv)); |
| return commandPath[0] === "channels" && commandPath[1] === "add"; |
| } |
|
|
| async function addChannelSetupOptions( |
| command: Command, |
| params: AddChannelSetupOptionsParams = {}, |
| ): Promise<ChannelSetupOptionRegistration> { |
| const { resolveChannelSetupCliOptionMetadata } = await loadChannelSetupCliOptions(); |
| const selected = params.channelId?.trim().toLowerCase(); |
| const { options, selectedChannel, valueMetadataByAttributeName } = |
| resolveChannelSetupCliOptionMetadata(selected, { |
| includeAll: params.includeAll, |
| }); |
| const mode: ChannelSetupOptionMode = selected |
| ? selectedChannel?.setup |
| ? "modern" |
| : "legacy" |
| : "none"; |
| const seenFlags = new Set(command.options.flatMap(getChannelSetupOptionSwitches)); |
| for (const option of options) { |
| addChannelSetupOption(command, option, seenFlags); |
| } |
| if ( |
| params.includeAll || |
| (mode === "legacy" && (selectedChannel === undefined || selectedChannel.setup === undefined)) |
| ) { |
| for (const option of LEGACY_CHANNEL_SETUP_OPTIONS) { |
| addChannelSetupOption(command, option, seenFlags); |
| } |
| } |
| const legacyIntDefaultAttributeNames = new Set<string>(); |
| for (const [attributeName, metadata] of valueMetadataByAttributeName) { |
| if (metadata.valueType === "int") { |
| legacyIntDefaultAttributeNames.add(attributeName); |
| } |
| } |
| return { mode, legacyIntDefaultAttributeNames }; |
| } |
|
|
| export async function registerChannelsCli( |
| program: Command, |
| argv: string[] = process.argv, |
| options: RegisterChannelsCliOptions = {}, |
| ) { |
| const channelNames = formatCliChannelOptions(); |
| const channels = program |
| .command("channels") |
| .description("Manage connected chat channels and accounts") |
| .option("--agent <id>", "Agent owner for channel commands that require workspace context") |
| .addHelpText( |
| "after", |
| () => |
| `\n${theme.heading("Examples:")}\n${formatHelpExamples([ |
| ["openclaw channels list", "List configured channels."], |
| ["openclaw channels list --all", "Show configured, bundled, and installable channels."], |
| ["openclaw channels add", "Open guided channel setup."], |
| ["openclaw channels status --probe", "Run channel status checks and probes."], |
| [ |
| "openclaw channels add --channel telegram --token <token>", |
| "Add or update a channel account non-interactively.", |
| ], |
| ["openclaw channels login --channel whatsapp", "Link a WhatsApp Web account."], |
| ])}\n\n${theme.muted("Docs:")} ${formatDocsLink( |
| "/cli/channels", |
| "docs.openclaw.ai/cli/channels", |
| )}\n`, |
| ); |
|
|
| channels |
| .command("list") |
| .description("List chat channels (configured by default; pass --all for installable catalog)") |
| .option("--all", "Include bundled and installable catalog channels", false) |
| .option("--json", "Output JSON", false) |
| .action(async (opts) => { |
| await runChannelsCommand(async () => { |
| const { channelsListCommand } = await import("../commands/channels/list.js"); |
| await channelsListCommand(opts, defaultRuntime); |
| }); |
| }); |
|
|
| channels |
| .command("status") |
| .description("Show channel status (use openclaw status --deep for a full connection check)") |
| .option("--channel <name>", `Only show one channel (${formatCliChannelOptions(["all"])})`) |
| .option("--probe", "Probe channel credentials", false) |
| .option("--timeout <ms>", "Timeout in ms", "10000") |
| .option("--json", "Output JSON", false) |
| .action(async (opts) => { |
| await runChannelsCommand(async () => { |
| const { channelsStatusCommand } = await import("../commands/channels/status.js"); |
| await channelsStatusCommand(opts, defaultRuntime); |
| }); |
| }); |
|
|
| channels |
| .command("capabilities") |
| .description("Show provider capabilities (intents/scopes + supported features)") |
| .option("--agent <id>", "Agent owner for channel discovery") |
| .option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`) |
| .option("--account <id>", "Account id (only with --channel)", parseAccountSelector) |
| .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, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsCapabilitiesCommand } = await loadChannelsCommands(); |
| await channelsCapabilitiesCommand( |
| { ...opts, agent: resolveStringOption(command, "agent") }, |
| defaultRuntime, |
| ); |
| }); |
| }); |
|
|
| channels |
| .command("resolve") |
| .description("Resolve channel/user names to IDs") |
| .argument("<entries...>", "Entries to resolve (names or ids)") |
| .option("--channel <name>", `Channel (${channelNames})`) |
| .option("--account <id>", "Account id (accountId)", parseAccountSelector) |
| .option("--agent <id>", "Agent owner for channel resolution") |
| .addOption( |
| new Option("--kind <kind>", "Target kind (auto|user|group|channel)") |
| .choices(["auto", "user", "group", "channel"]) |
| .default("auto"), |
| ) |
| .option("--json", "Output JSON", false) |
| .action(async (entries, opts, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsResolveCommand } = await loadChannelsCommands(); |
| await channelsResolveCommand( |
| { |
| agent: resolveStringOption(command, "agent"), |
| channel: opts.channel as string | undefined, |
| account: opts.account as string | undefined, |
| kind: opts.kind as "auto" | "user" | "group" | "channel", |
| json: Boolean(opts.json), |
| entries: Array.isArray(entries) ? entries : [String(entries)], |
| }, |
| defaultRuntime, |
| ); |
| }); |
| }); |
|
|
| channels |
| .command("logs") |
| .description("Show recent channel logs from the gateway log file") |
| .option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])}; default: all)`) |
| .option("--lines <n>", "Number of lines (default: 200)", "200") |
| .option("--json", "Output JSON", false) |
| .action(async (opts) => { |
| await runChannelsCommand(async () => { |
| const { channelsLogsCommand } = await loadChannelsCommands(); |
| await channelsLogsCommand(opts, defaultRuntime); |
| }); |
| }); |
|
|
| const deadLetters = channels |
| .command("dead-letters") |
| .description("Inspect and resubmit failed inbound channel events") |
| .option("--account <id>", "Account id", "default"); |
|
|
| deadLetters |
| .command("list") |
| .description("List failed inbound events for one channel account") |
| .requiredOption("--channel <name>", "Channel id") |
| .option("--account <id>", "Account id", "default") |
| .option("--limit <n>", "Maximum entries", "100") |
| .option("--json", "Output JSON", false) |
| .action(async (opts, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsDeadLettersListCommand } = |
| await import("../commands/channels/dead-letters.js"); |
| await channelsDeadLettersListCommand( |
| { ...opts, account: resolveStringOption(command, "account") }, |
| defaultRuntime, |
| ); |
| }); |
| }); |
|
|
| deadLetters |
| .command("resubmit") |
| .description("Re-enqueue one failed inbound event") |
| .argument("<event-id>", "Ingress event id") |
| .requiredOption("--channel <name>", "Channel id") |
| .option("--account <id>", "Account id", "default") |
| .option("--json", "Output JSON", false) |
| .action(async (eventId, opts, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsDeadLettersResubmitCommand } = |
| await import("../commands/channels/dead-letters.js"); |
| await channelsDeadLettersResubmitCommand( |
| eventId, |
| { ...opts, account: resolveStringOption(command, "account") }, |
| defaultRuntime, |
| ); |
| }); |
| }); |
|
|
| applyParentDefaultHelpAction(deadLetters); |
|
|
| const addCommand = channels |
| .command("add") |
| .description("Add or update a channel account") |
| .argument("[channel]", "Channel id") |
| .addHelpText( |
| "after", |
| () => |
| `\n${theme.heading("Examples:")}\n${formatHelpExamples([ |
| ["openclaw channels add", "Open guided setup for available chat channels."], |
| [ |
| "openclaw channels add --channel telegram --token <token>", |
| "Add or update Telegram non-interactively.", |
| ], |
| ["openclaw channels list --all", "Find channel ids before using --channel."], |
| ])}\n`, |
| ) |
| .option("--channel <name>", `Channel (${channelNames})`) |
| .option("--agent <id>", "Agent owner for channel setup") |
| .option("--account <id>", "Account id (default when omitted)") |
| .option("--name <name>", "Display name for this account"); |
|
|
| let channelSetupRegistration: ChannelSetupOptionRegistration = { |
| mode: "none", |
| legacyIntDefaultAttributeNames: new Set(), |
| }; |
| const selectedChannelId = await resolveChannelsAddChannelFromArgv(argv); |
| if ( |
| shouldRegisterChannelSetupOptions(argv, options) && |
| (selectedChannelId !== undefined || options.includeSetupOptions) |
| ) { |
| channelSetupRegistration = await addChannelSetupOptions(addCommand, { |
| channelId: selectedChannelId, |
| includeAll: options.includeSetupOptions, |
| }); |
| } |
|
|
| addCommand.action(async (channelArg: string | undefined, opts, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsAddCommand } = await loadChannelsCommands(); |
| const hasFlags = hasExplicitOptions( |
| command, |
| getOptionNames(command).filter((name) => !CHANNEL_ADD_SELECTION_OPTION_NAMES.has(name)), |
| ); |
| await channelsAddCommand( |
| { |
| ...resolveChannelsAddOptions( |
| channelArg, |
| opts, |
| command, |
| channelSetupRegistration.mode === "modern" |
| ? undefined |
| : { |
| preserveLegacyDefaults: true, |
| dropEmptyLegacyDefaultsForAttributeNames: |
| channelSetupRegistration.legacyIntDefaultAttributeNames, |
| }, |
| ), |
| agent: resolveStringOption(command, "agent"), |
| }, |
| defaultRuntime, |
| { |
| hasFlags, |
| }, |
| ); |
| }); |
| }); |
|
|
| channels |
| .command("remove") |
| .description("Disable or delete a channel account") |
| .option("--agent <id>", "Agent owner for channel discovery") |
| .option("--channel <name>", `Channel (${channelNames})`) |
| .option("--account <id>", "Account id (default when omitted)") |
| .option("--delete", "Delete config entries (no prompt)", false) |
| .action(async (opts, command) => { |
| await runChannelsCommand(async () => { |
| const { channelsRemoveCommand } = await loadChannelsCommands(); |
| const hasFlags = hasExplicitOptions(command, optionNamesRemove); |
| await channelsRemoveCommand( |
| { ...opts, agent: resolveStringOption(command, "agent") }, |
| defaultRuntime, |
| { hasFlags }, |
| ); |
| }); |
| }); |
|
|
| for (const mode of ["login", "logout"] as const) { |
| const auth = channels |
| .command(mode) |
| .description( |
| mode === "login" |
| ? "Link a channel account (if supported)" |
| : "Log out of a channel session (if supported)", |
| ) |
| .option("--agent <id>", "Agent owner for channel discovery") |
| .option("--channel <channel>", "Channel alias (auto when only one is configured)") |
| .option("--account <id>", "Account id (accountId)"); |
| if (mode === "login") { |
| auth.option("--verbose", "Verbose connection logs", false); |
| } |
| auth.action(async (opts, command) => { |
| await runChannelsCommandWithDanger( |
| () => |
| (mode === "login" ? runChannelLogin : runChannelLogout)( |
| { |
| agent: resolveStringOption(command, "agent"), |
| channel: opts.channel as string | undefined, |
| account: opts.account as string | undefined, |
| ...(mode === "login" ? { verbose: Boolean(opts.verbose) } : {}), |
| }, |
| defaultRuntime, |
| ), |
| `Channel ${mode} failed`, |
| ); |
| }); |
| } |
|
|
| applyParentDefaultHelpAction(channels); |
| } |
|
|