📄 onboarding.ts

/**
 * QQBot CLI Onboarding Adapter
 * 
 * 提供 openclaw onboard 命令的交互式配置支持
 */
import type { 
  ChannelOnboardingAdapter,
  ChannelOnboardingStatus,
  ChannelOnboardingStatusContext,
  ChannelOnboardingConfigureContext,
  ChannelOnboardingResult,
  OpenClawConfig,
} from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount } from "./config.js";

// 内部类型(用于类型安全)
interface QQBotChannelConfig {
  enabled?: boolean;
  appId?: string;
  clientSecret?: string;
  clientSecretFile?: string;
  name?: string;
  imageServerBaseUrl?: string;
  markdownSupport?: boolean;
  allowFrom?: string[];
  accounts?: Record<string, {
    enabled?: boolean;
    appId?: string;
    clientSecret?: string;
    clientSecretFile?: string;
    name?: string;
    imageServerBaseUrl?: string;
    markdownSupport?: boolean;
    allowFrom?: string[];
  }>;
}

// Prompter 类型定义
interface Prompter {
  note: (message: string, title?: string) => Promise<void>;
  confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean>;
  text: (opts: { message: string; placeholder?: string; initialValue?: string; validate?: (value: string) => string | undefined }) => Promise<string>;
  select: <T>(opts: { message: string; options: Array<{ value: T; label: string }>; initialValue?: T }) => Promise<T>;
}

/**
 * 解析默认账户 ID
 */
function resolveDefaultQQBotAccountId(cfg: OpenClawConfig): string {
  const ids = listQQBotAccountIds(cfg);
  return ids[0] ?? DEFAULT_ACCOUNT_ID;
}

/**
 * QQBot Onboarding Adapter
 */
export const qqbotOnboardingAdapter: ChannelOnboardingAdapter = {
  channel: "qqbot" as any,

  getStatus: async (ctx: ChannelOnboardingStatusContext): Promise<ChannelOnboardingStatus> => {
    const cfg = ctx.cfg as OpenClawConfig;
    const configured = listQQBotAccountIds(cfg).some((accountId) => {
      const account = resolveQQBotAccount(cfg, accountId);
      return Boolean(account.appId && account.clientSecret);
    });

    return {
      channel: "qqbot" as any,
      configured,
statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecret"}`],
      selectionHint: configured ? "已配置" : "支持 QQ 群聊和私聊(流式消息)",
      quickstartScore: configured ? 1 : 20,
    };
  },

  configure: async (ctx: ChannelOnboardingConfigureContext): Promise<ChannelOnboardingResult> => {
    const cfg = ctx.cfg as OpenClawConfig;
    const prompter = ctx.prompter as Prompter;
    const accountOverrides = ctx.accountOverrides as Record<string, string> | undefined;
    const shouldPromptAccountIds = ctx.shouldPromptAccountIds;
    
    const qqbotOverride = accountOverrides?.qqbot?.trim();
    const defaultAccountId = resolveDefaultQQBotAccountId(cfg);
    let accountId = qqbotOverride ?? defaultAccountId;

    // 是否需要提示选择账户
    if (shouldPromptAccountIds && !qqbotOverride) {
      const existingIds = listQQBotAccountIds(cfg);
      if (existingIds.length > 1) {
        accountId = await prompter.select({
          message: "选择 QQBot 账户",
          options: existingIds.map((id) => ({
            value: id,
            label: id === DEFAULT_ACCOUNT_ID ? "默认账户" : id,
          })),
          initialValue: accountId,
        });
      }
    }

    let next: OpenClawConfig = cfg;
    const resolvedAccount = resolveQQBotAccount(next, accountId);
    const accountConfigured = Boolean(resolvedAccount.appId && resolvedAccount.clientSecret);
    const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
    const envAppId = typeof process !== "undefined" ? process.env?.QQBOT_APP_ID?.trim() : undefined;
    const envSecret = typeof process !== "undefined" ? process.env?.QQBOT_CLIENT_SECRET?.trim() : undefined;
    const canUseEnv = allowEnv && Boolean(envAppId && envSecret);
    const hasConfigCredentials = Boolean(resolvedAccount.config.appId && resolvedAccount.config.clientSecret);

    let appId: string | null = null;
    let clientSecret: string | null = null;

    // 显示帮助
    if (!accountConfigured) {
      await prompter.note(
        [
          "1) 打开 QQ 开放平台: https://q.qq.com/",
          "2) 创建机器人应用,获取 AppID 和 ClientSecret",
          "3) 在「开发设置」中添加沙箱成员(测试阶段)",
          "4) 你也可以设置环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET",
          "",
          "文档: https://bot.q.qq.com/wiki/",
          "",
          "此版本支持流式消息发送!",
        ].join("\n"),
"QQ Bot 配置",
      );
    }

    // 检测环境变量
    if (canUseEnv && !hasConfigCredentials) {
      const keepEnv = await prompter.confirm({
        message: "检测到环境变量 QQBOT_APP_ID 和 QQBOT_CLIENT_SECRET,是否使用?",
        initialValue: true,
      });
      if (keepEnv) {
        next = {
          ...next,
          channels: {
            ...next.channels,
            qqbot: {
              ...(next.channels?.qqbot as Record<string, unknown> || {}),
              enabled: true,
              allowFrom: resolvedAccount.config?.allowFrom ?? ["*"],
            },
          },
        };
      } else {
        // 手动输入
        appId = String(
          await prompter.text({
            message: "请输入 QQ Bot AppID",
            placeholder: "例如: 102146862",
            initialValue: resolvedAccount.appId || undefined,
            validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
          }),
        ).trim();
        clientSecret = String(
          await prompter.text({
            message: "请输入 QQ Bot ClientSecret",
            placeholder: "你的 ClientSecret",
            validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
          }),
        ).trim();
      }
    } else if (hasConfigCredentials) {
      // 已有配置
      const keep = await prompter.confirm({
        message: "QQ Bot 已配置,是否保留当前配置?",
        initialValue: true,
      });
      if (!keep) {
        appId = String(
          await prompter.text({
            message: "请输入 QQ Bot AppID",
            placeholder: "例如: 102146862",
            initialValue: resolvedAccount.appId || undefined,
            validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
          }),
        ).trim();
        clientSecret = String(
          await prompter.text({
            message: "请输入 QQ Bot ClientSecret",
            placeholder: "你的 ClientSecret",
            validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
          }),
        ).trim();
      }
    } else {
      // 没有配置,需要输入
      appId = String(
        await prompter.text({
          message: "请输入 QQ Bot AppID",
          placeholder: "例如: 102146862",
          initialValue: resolvedAccount.appId || undefined,
          validate: (value: string) => (value?.trim() ? undefined : "AppID 不能为空"),
        }),
      ).trim();
      clientSecret = String(
        await prompter.text({
          message: "请输入 QQ Bot ClientSecret",
          placeholder: "你的 ClientSecret",
          validate: (value: string) => (value?.trim() ? undefined : "ClientSecret 不能为空"),
        }),
      ).trim();
    }

    // 默认允许所有人执行命令(用户无感知)
    const allowFrom: string[] = resolvedAccount.config?.allowFrom ?? ["*"];

    // 应用配置
    if (appId && clientSecret) {
      // 询问是否启用 Markdown 支持
      const enableMarkdown = await prompter.confirm({
        message: "是否启用 Markdown 消息格式?(需要机器人具备该权限,默认关闭)",
        initialValue: false,
      });

      if (accountId === DEFAULT_ACCOUNT_ID) {
        next = {
          ...next,
          channels: {
            ...next.channels,
            qqbot: {
              ...(next.channels?.qqbot as Record<string, unknown> || {}),
              enabled: true,
              appId,
              clientSecret,
              markdownSupport: enableMarkdown,
              allowFrom,
            },
          },
        };
      } else {
        next = {
          ...next,
          channels: {
            ...next.channels,
            qqbot: {
              ...(next.channels?.qqbot as Record<string, unknown> || {}),
              enabled: true,
              accounts: {
                ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts || {}),
                [accountId]: {
                  ...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
                  enabled: true,
                  appId,
                  clientSecret,
                  markdownSupport: enableMarkdown,
                  allowFrom,
                },
              },
            },
          },
        };
      }
    }

    return { success: true, cfg: next as any, accountId };
  },

  disable: (cfg: unknown) => {
    const config = cfg as OpenClawConfig;
    return {
      ...config,
      channels: {
        ...config.channels,
        qqbot: { ...(config.channels?.qqbot as Record<string, unknown> || {}), enabled: false },
      },
    } as any;
  },
};