import { Command } from 'commander' import inquirer from 'inquirer' import fs from 'fs' import path, { dirname } from 'path' import { fileURLToPath } from 'url' import { env, getWechatRuntimeConfig } from './config/env.js' import { analyzeWechatMessages } from './analysis/wechatAnalyzer.js' import { larkListMessages, larkLogin, larkSearchMessages, larkSendText, larkStatus } from './adapters/lark.js' import { runOpenCli, runWxCli } from './adapters/opencli.js' import { runPi } from './adapters/pi.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const { version, name } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf8')) export const serveList = [ { name: 'ChatGPT', value: 'ChatGPT' }, { name: 'doubao', value: 'doubao' }, { name: 'deepseek', value: 'deepseek' }, { name: 'Kimi', value: 'Kimi' }, { name: 'Xunfei', value: 'Xunfei' }, { name: 'deepseek-free', value: 'deepseek-free' }, { name: '302AI', value: '302AI' }, { name: 'dify', value: 'dify' }, { name: 'ollama', value: 'ollama' }, { name: 'tongyi', value: 'tongyi' }, { name: 'claude', value: 'claude' }, { name: 'pi', value: 'pi' }, ] function getMissingConfig(type) { switch (type) { case 'ChatGPT': return env.OPENAI_API_KEY ? [] : ['OPENAI_API_KEY'] case 'doubao': return env.DOUBAO_API_KEY ? [] : ['DOUBAO_API_KEY'] case 'deepseek': return env.DEEPSEEK_API_KEY ? [] : ['DEEPSEEK_API_KEY'] case 'Kimi': return env.KIMI_API_KEY ? [] : ['KIMI_API_KEY'] case 'Xunfei': return env.XUNFEI_APP_ID && env.XUNFEI_API_KEY && env.XUNFEI_API_SECRET ? [] : ['XUNFEI_APP_ID', 'XUNFEI_API_KEY', 'XUNFEI_API_SECRET'] case 'deepseek-free': return env.DEEPSEEK_FREE_URL && env.DEEPSEEK_FREE_TOKEN && env.DEEPSEEK_FREE_MODEL ? [] : ['DEEPSEEK_FREE_URL', 'DEEPSEEK_FREE_TOKEN', 'DEEPSEEK_FREE_MODEL'] case '302AI': return env._302AI_API_KEY ? [] : ['_302AI_API_KEY'] case 'dify': return env.DIFY_API_KEY && env.DIFY_URL ? [] : ['DIFY_API_KEY', 'DIFY_URL'] case 'ollama': return env.OLLAMA_URL && env.OLLAMA_MODEL ? [] : ['OLLAMA_URL', 'OLLAMA_MODEL'] case 'tongyi': return env.TONGYI_URL && env.TONGYI_MODEL ? [] : ['TONGYI_URL', 'TONGYI_MODEL'] case 'claude': return env.CLAUDE_API_KEY && env.CLAUDE_MODEL ? [] : ['CLAUDE_API_KEY', 'CLAUDE_MODEL'] case 'pi': return [] default: return ['SERVICE_TYPE'] } } async function startWechat(type) { const serviceType = type || env.SERVICE_TYPE if (!serveList.find((item) => item.value === serviceType)) { console.log('服务类型错误,目前支持:' + serveList.map((item) => item.value).join(' | ')) return } const missing = getMissingConfig(serviceType) if (missing.length) { console.log(`请先配置 .env 文件中的 ${missing.join(',')}`) return } console.log('service type:', serviceType) const { startWechatBot } = await import('./platforms/wechat/bot.js') startWechatBot({ serviceType }) } async function promptAndStart() { if (env.SERVICE_TYPE) { await startWechat(env.SERVICE_TYPE) return } const answer = await inquirer.prompt([ { type: 'list', name: 'serviceType', message: '请先选择服务类型', choices: serveList, }, ]) await startWechat(answer.serviceType) } function printAnalysisResult(result) { console.log(`分析对象:${result.target}`) console.log(JSON.stringify(result.stats, null, 2)) if (result.analysis) { console.log('\n分析结果:') console.log(result.analysis) } } const program = new Command(name) program.alias('we').description('一个基于 WeChaty 结合 AI 服务实现的微信机器人。').version(version, '-v, --version, -V') program.option('-s, --serve ', '跳过交互,直接设置启动的服务类型').action(async () => { const { serve } = program.opts() if (serve) { await startWechat(serve) return } await promptAndStart() }) program .command('start') .description('启动微信 IM,终端展示二维码扫码登录') .option('-s, --serve ', '跳过交互,直接设置启动的服务类型') .action(async (options) => { if (options.serve) { await startWechat(options.serve) return } await promptAndStart() }) program .command('agent') .description('启动外部 IM 通道,并使用指定 agent 处理消息') .option('--im ', '外部通信渠道:wechat', 'wechat') .option('--agent ', '消息处理 agent:pi 或其他 serve 类型', 'pi') .action(async (options) => { if (options.im !== 'wechat') { console.log('当前 agent 命令只支持 --im wechat。飞书可先使用 wb lark login/send/messages/search。') return } await startWechat(options.agent) }) program .command('analyze') .description('分析本地捕获的微信聊天记录') .option('--room ', '按群聊名称分析') .option('--friend ', '按好友昵称或备注分析') .option('--query ', '只分析包含关键词的消息') .option('--start ', '开始时间 ISO 8601') .option('--end ', '结束时间 ISO 8601') .option('--limit ', '最多读取最近 N 条本地消息', '5000') .option('-s, --serve ', '用于生成深度分析的 AI 服务', env.SERVICE_TYPE || 'ChatGPT') .option('--stats-only', '只输出统计,不调用 AI 服务') .action(async (options) => { const config = getWechatRuntimeConfig() const result = await analyzeWechatMessages({ ...options, serviceType: options.serve, dataDir: config.dataDir, limit: Number(options.limit), }) printAnalysisResult(result) }) const lark = program.command('lark').description('飞书 IM 登录、发消息和读取消息') lark .command('login') .description('使用 lark-cli device flow 登录飞书 IM') .option('--scope ', '指定 scope,例:im:message:readonly') .option('--domain ', '按 domain 授权', 'im') .option('--no-wait', '只生成授权链接/扫码信息,不阻塞等待授权完成') .option('--device-code ', '继续完成上一次 --no-wait 返回的 device_code') .action(async (options) => { await larkLogin(options) }) lark .command('status') .description('查看当前飞书授权状态') .action(async () => { await larkStatus() }) lark .command('send') .description('发送飞书 IM 文本消息') .option('--as ', 'user 或 bot', 'user') .option('--chat-id ', '群聊 ID,oc_xxx') .option('--user-id ', '用户 open_id,ou_xxx') .requiredOption('--text ', '文本内容') .action(async (options) => { await larkSendText(options) }) lark .command('messages') .description('读取某个飞书群聊或 P2P 会话消息') .option('--as ', 'user 或 bot', 'user') .option('--chat-id ', '群聊 ID,oc_xxx') .option('--user-id ', '用户 open_id,ou_xxx') .option('--start ', '开始时间 ISO 8601') .option('--end ', '结束时间 ISO 8601') .option('--page-size ', '分页大小', '50') .option('--format ', 'json | pretty | table | ndjson | csv', 'pretty') .action(async (options) => { await larkListMessages(options) }) lark .command('search') .description('搜索飞书 IM 消息') .option('--query ', '搜索关键词') .option('--chat-id ', '限制群聊 ID') .option('--chat-type ', 'group 或 p2p') .option('--start ', '开始时间 ISO 8601') .option('--end ', '结束时间 ISO 8601') .option('--page-all', '自动翻页') .option('--page-limit ', '最多翻页数', '20') .option('--format ', 'json | pretty | table | ndjson | csv', 'pretty') .action(async (options) => { await larkSearchMessages(options) }) program .command('opencli') .description('透传调用 OpenCLI,用于本地微信、朋友圈或其他本机工具') .allowUnknownOption(true) .argument('[args...]') .action(async (args) => { await runOpenCli(args) }) program .command('wx') .description('通过 OpenCLI wx-cli 访问本地微信聊天、联系人、群成员和朋友圈缓存') .allowUnknownOption(true) .argument('[args...]') .action(async (args) => { await runWxCli(args) }) program .command('pi') .description('透传调用 Pi coding agent') .allowUnknownOption(true) .argument('[args...]') .action(async (args) => { await runPi(args) }) program.parseAsync().catch((error) => { console.error(error.message) process.exitCode = 1 })